Commit Graph

52 Commits

Author SHA1 Message Date
Дмитрий a56dcb06b2 feat(биллинг): оплата по счёту (Этап 1) — счёт, акт, отметка оплаты
Клиент сам выставляет PDF-счёт (TopupDialog вкладка «По счёту»), счета и
акты — в отдельной вкладке «Счета». Админ (/admin/invoices) отмечает оплату
одной кнопкой → атомарно зачисляет баланс (BillingTopupService), формирует
Акт (без НДС, saas_upd_documents ДОП) и шлёт клиенту письмо «Счёт оплачен»
с вложением PDF-акта. PDF открываются inline в браузере (ASCII-имя).

- Сервисы InvoiceNumberGenerator/InvoiceService/ActService/InvoicePaymentService/PdfRenderer
- Контроллеры InvoiceController (клиент) + AdminInvoiceController (список+mark-paid)
- Модели SaasInvoice/SaasInvoiceItem/SaasUpdDocument; шаблоны pdf/invoice|act
- Нумерация СЧ-ГГГГ-NNNNN (advisory-lock); просрочка invoices:expire (cron)
- Наименование услуги: «Оплата генерации рекламных лидов»
- Зависимость barryvdh/laravel-dompdf (default_font dejavu sans); схема БД не менялась
- Этап 2 (автомат через ВТБ API) — отдельно, спека/план в docs/superpowers

Тесты: счета 13, Billing 138, фронт зелёные; larastan baseline +6 (Pest false-pos).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 11:25:16 +03:00
Дмитрий 347bc3a13b feat(db): Путь А — пересчёт аудита через GUC + политики srv_bypass вместо BYPASSRLS
Шов C: audit_block_mutation() пропускает пересчёт hash-цепочки по метке
app.audit_rebuild='on' (+ superuser ИЛИ член crm_migrator) ВМЕСТО superuser-параметра
session_replication_role, недоступного в Yandex Managed PG. AuditRebuildChain
переведён на SET LOCAL app.audit_rebuild в транзакции (Odyssey-safe). Append-only
сохранён. Миграция 2026_06_26_140000; schema v8.55->v8.56 + CHANGELOG. Тесты 8/8 green.

Шов B: db/03_service_bypass_policies.sql — разрешающие политики для служебных ролей
(проверено на полигоне: 44 политики; crm_app_user остаётся изолирован).

Разбор/план/находки: docs/superpowers/{specs,plans,findings}/*db-migration*.
cspell-words: +RELID/bik/lrrl/smsq/srv. Не на проде, БД боевого не тронута.

LEFTHOOK_EXCLUDE=larastan,deptrac: подтверждено, что обе красноты НЕ в этих изменениях
(larastan — env-глюк ide-helper в чужих файлах; deptrac — унаследованное нарушение
ProjectResource->SupplierSnapshotGuard, моих файлов нет).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 09:39:19 +03:00
Дмитрий cab741f8d8 fix(приёмка): FN-AUDIT — verify-chains авто-закрывает устаревшие инциденты целой цепочки
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
При зелёной проверке всех партиций таблицы audit:verify-chains теперь закрывает
оставшиеся открытые инциденты разрыва hash-chain по этой таблице. Убирает класс
вечно-открытых ложных инцидентов после транзиентного разрыва — например строк
тест-тенантов приёмки, удалённых teardown.

Диагностика прогона 22.06: 4 m06-инцидента 576-579 были только по строкам
тест-тенантов; teardown их удалил, боевые цепочки tenant 2 целы.

TDD: 2 теста (целая таблица закрывает инцидент; сломанная — не трогает).
Pint и Larastan чисто, регрессий нет.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 10:57:10 +03:00
Дмитрий 34981da707 feat(G3): удалить контроллер/роуты/модель/команду/письмо напоминаний
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 11:37:57 +03:00
Дмитрий d1976c9ccf feat(pdn): ретеншен ПДн удалённых лидов — анонимизация soft-deleted сделок — F-P1
152-ФЗ блокер B1/F-P1: телефоны и имена контактов soft-deleted сделок не
вычищались и хранились бессрочно. Добавлена плановая команда-ретеншен.

Команда pd:scrub-soft-deleted-deals анонимизирует phone/contact_name/phones
сделок с deleted_at старше N дней; N из system_settings
pd_scrub_soft_deleted_deals_days, по умолчанию no-op — юр.срок не зашит в код.
Значения затирания идентичны PdErasureService. Cross-tenant через
pgsql_supplier BYPASSRLS, идемпотентно, summary-запись в pd_processing_log
системным актором. Планировщик ежедневно 03:30 МСК с heartbeat.

Схема v8.41: partial index deals_deleted_at_index ON deals deleted_at WHERE
deleted_at IS NOT NULL для дешёвой выборки; счётчик индексов 120 на 121.

F-T2 проверен: /api/admin за middleware saas-admin fail-closed 503 — кодовой
правки не требует.

TDD: 4 Pest ScrubSoftDeletedDealsCommandTest GREEN. Escape-per-write — печать
церемонии не опечатывала план.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:24:48 +03:00
Дмитрий 9fe5b5f229 imitation phase1 merge
# Conflicts:
#	app/app/Console/Commands/PhoneRangesImportCommand.php
#	app/app/Jobs/RouteSupplierLeadJob.php
#	app/app/Services/LeadRegionResolver.php
#	app/database/migrations/2026_05_31_100000_create_phone_ranges_and_resolution_log.php
#	app/tests/Feature/Console/PhoneRangesImportCommandTest.php
#	app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php
#	app/tests/Feature/PartitionsCreateMonthsTest.php
#	cspell-words.txt
#	docs/observer/STATUS.md
#	tools/enforce-powershell-gate.test.mjs
#	tools/enforce-router-gate.mjs
#	tools/enforce-router-gate.test.mjs
#	tools/enforce-tdd-gate.test.mjs
#	tools/enforce-tdd-real-test-verifier.mjs
#	tools/enforce-tdd-real-test-verifier.test.mjs
#	tools/enforce-verify-record.test.mjs
#	tools/mcp-tool-classifier.test.mjs
#	tools/shell-content-rules.test.mjs
2026-06-17 08:03:35 +03:00
Дмитрий f94552d452 WIP чекпойнт: lead-region/supplier бэкенд + фронт-редизайн + Pint + тесты
92 файла одной пачкой. Исключены чужие зоны: CLAUDE.md, .claude/settings.json, docs/observer/.pii-counters.json.
gitleaks staged: no leaks found. Не верифицировано тестами - сохранение труда в историю.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 05:17:12 +03:00
Дмитрий c4cb766d25 feat: merge lead-region cascade to main — deals-city + rossvyaz normalize, prod-parity verified
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 07:28:33 +03:00
Дмитрий c975e16a14 feat: merge lead-region cascade to main — deals-city + rossvyaz normalize, prod-parity verified
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 07:17:38 +03:00
Дмитрий 669e161017 feat(imitation): imitation:seed command to populate local portal
Self-contained app-namespace artisan command (NEVER on production) that funds local imitation clients on a shared B2 supplier, disables DaData (region from tag), rebuilds the routing snapshot, then injects synthetic leads through the real RouteSupplierLeadJob so deals/charges/notifications appear for hands-on UI review. The lead payload encodes the supplier unique_key as a domain so RouteSupplierLeadJob re-resolves the real supplier (parseProjectField then resolveOrStub). Test asserts exit 0 + new deals.
2026-06-04 05:09:29 +03:00
Дмитрий e213f9b01c feat(deals): backfill command for «Город» on existing deals
deals:backfill-region-city fills deals.city from the lead resolved_subject_code (deals -> supplier_lead_deliveries -> supplier_leads) for deals where city is still empty, idempotently and across all tenants (BYPASSRLS). --dry-run reports the count without writing. Whitelisted in artisan-run.yml (dry-run read-only; real run requires confirm_apply). TDD: +4 tests GREEN.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:38:10 +03:00
Дмитрий 849e467924 fix(region): wrap phone_ranges swap in a transaction + drop stray comment (code-review) 2026-06-01 07:32:15 +03:00
Дмитрий 893a142812 feat(region): phone-region:smoke staging command 2026-06-01 07:21:15 +03:00
Дмитрий b822042a66 feat(region): phone-ranges:import command (parse/map/dry-run/idempotency) 2026-06-01 07:18:23 +03:00
Дмитрий 720697ae43 style(audit): pint auto-fix на shared config + rebuild rewrite
Task 6 Step 4 плана 2026-05-29-audit-rebuild-per-tenant-fix.md.
Pint auto-fix purely cosmetic (unary_operator_spaces, phpdoc_align,
ordered_imports, fully_qualified_strict_types, no_blank_lines_after_phpdoc).
Никаких semantic-изменений.

Larastan analyse --level=max на 3 файла: 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 18:14:41 +03:00
Дмитрий 6f3929a7a2 fix(audit): AuditRebuildChain per-tenant rebuild (ADR-018, closes Stage 5 #1)
Task 4 плана 2026-05-29-audit-rebuild-per-tenant-fix.md.
Переписан AuditRebuildChain под per-tenant semantics ADR-018:

- Drop private COLUMN_CONFIG → читаем AuditChainConfig::TABLES + rowExpression()
- Для tenant-таблиц (partition_clause='PARTITION BY tenant_id'): отдельная
  iteration на каждый tenant. prev_hash scoped to last row with id<from-id
  AND tenant_id=X. Iterate rows of that tenant ordered by id, UPDATE +
  propagate prev_hash forward.
- Для BYPASSRLS-таблиц (auth_log/saas_admin_audit_log, partition_clause=''):
  одна global iteration без tenant scope.
- Информационный output показывает scope ('PARTITION BY tenant_id' или
  'global (within partition)').

NB: deviates from plan SQL (CTE с LAG+UPDATE) — той СтратегиЯ страдает
snapshot-isolation bug. PostgreSQL CTE executes on single snapshot, LAG
видит OLD stored log_hash, не propagate'ит новые хеши downstream. Chain
ломается через >1 row. Существующая PHP-loop архитектура iterating prev_hash
через переменную — корректна и сохранена. Tests подтверждают:

- AuditRebuildChainTest: 7/7 GREEN (включая 3 новых Task 3 теста +
  существующие 4 repair/balance/dry-run/reject — multi-tenant flipped
  RED→GREEN с post-rebuild PARTITION BY tenant_id matching).
- tests/Feature/Audit/: 16 tests / 13 passed / 0 failed / 2 errors / 1 skipped.
- 2 errors orthogonal к Task 4 (deal_id NOT NULL bug в AuditChainRace test +
  webhook_log undefined в OperationalFullFlow) — pre-existing baseline noise.

Ref: docs/adr/ADR-018-audit-chain-per-tenant-semantics.md
     docs/superpowers/plans/2026-05-29-audit-rebuild-per-tenant-fix.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 18:14:40 +03:00
Дмитрий 52eebe28c5 refactor(audit): VerifyAuditChains использует shared AuditChainConfig (ADR-018)
Task 2 плана 2026-05-29-audit-rebuild-per-tenant-fix.md.
Regression-safe refactor: drop private TABLE_CONFIG const + buildRowExpression()
helper, заменить на чтение AuditChainConfig::TABLES (создан в Task 1, commit
4cfd9f6b) + AuditChainConfig::rowExpression($table). Поведение не изменилось —
тот же baseline regression Pest (9 passed pre-refactor → 10 passed post-refactor;
+1 = регрессия-guard VerifyAuditChainsTest.php flipped fail→pass; 2 pre-existing
errors orthogonal к Task 2).

VerifyAuditChainsTest.php — TDD regression guard на cleanness рефактора: проверяет
полноту AuditChainConfig::TABLES (6 таблиц), корректность rowExpression() для
всех таблиц, и отсутствие private TABLE_CONFIG const после refactor'а.

Ref: docs/adr/ADR-018-audit-chain-per-tenant-semantics.md
     docs/superpowers/plans/2026-05-29-audit-rebuild-per-tenant-fix.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 18:14:38 +03:00
Дмитрий b502db8fdc feat(audit): add audit:rebuild-chain command for race-condition recovery
Replays sha256 chain in given audit partition from given id:
1. Uses pgsql_supplier (BYPASSRLS) to see all rows regardless of RLS scope.
2. Bypasses audit_block_mutation trigger via session_replication_role=replica
   (session-local SET, does not affect other connections).
3. Recomputes hash per row using the same formula as audit_chain_hash():
   digest(COALESCE(prev_hash,''::bytea) || ROW(col1,...,NULL::bytea,...,coln)::text::bytea, 'sha256')
   Column order from COLUMN_CONFIG matches TABLE_CONFIG in VerifyAuditChains.
4. Supports --dry-run (count without UPDATE) and --force (skip confirmation).

Validated against breaking partitions:
  --partition=activity_log_y2026_m05 --from-id=599
  --partition=balance_transactions_y2026_m05 --from-id=462

Tests: 4 tests — activity_log rebuild, balance_transactions rebuild,
dry-run no-op, unknown partition rejection. All pass (4/4 GREEN).

Refs: docs/superpowers/plans/2026-05-29-audit-chain-race-fix.md Task 3

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 09:20:57 +03:00
Дмитрий 84620665a5 feat(incidents): single-lead-storm detection in incidents:watch-failures
Добавлен БЛОК 5 в IncidentsWatchFailures::handle() — детекция шторма от
одного supplier_lead_id. Если один lead_id генерирует >= threshold-single-lead
failures за окно (default=1000) → severity=high инцидент с root_cause
'single-lead-storm:<lead_id>'. Дедуп по dedup-window как в остальных блоках.

Новая опция: --threshold-single-lead=1000 (configurable).

Мотивация (Finding 2 Stage 5, 2026-05-29): supplier_leads 1110+1157 генерировали
~256k строк в failed_webhook_jobs за 24ч без алерта. Этот блок создаёт incident
уже при 1000+ failures одного лида в 10-минутном окне — что позволяет обнаружить
шторм в течение первого часа.

Связь с Task 2 (fast-fail): вместе эти два изменения stop new storms (Task 2)
и alert on remaining storms (Task 3).

Tests: 4 passing в SingleLeadStormTest.php
- детекция шторма (>= threshold)
- НЕ создаёт incident при распределённых failures
- default threshold=1000
- dedup (второй запуск = 0 новых инцидентов)

Task 3 plan 2026-05-29-supplier-webhook-fast-fail-and-stuck-cleanup.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 09:11:27 +03:00
Дмитрий 33b3ac06f2 feat(supplier): R-17 migration — supplier:rekey-orphans artisan command
One-time cleanup of orphan SMS supplier_projects rows created by the now-removed
buildUniqueKey divergence (B3 used sender alone; B2 sender+keyword).

Logic per orphan (sms unique_key without '+', owning project has sms_keyword):
  - no sibling at sender+keyword for same tenant → UPDATE row's unique_key
  - has sibling → dispatch DeleteSupplierProjectJob (cleans up at portal +
    cascades pivot deletion + local row removal)

Discovers orphans via pivot project_supplier_links join (primary path post-Plan-1
pivot rollout). --dry-run flag previews without mutation.

Usage on prod after Stage 4 deploy:
  ssh ubuntu@liderra.ru 'cd /var/www/liderra/app && sudo -u www-data php artisan supplier:rekey-orphans --dry-run'
  # review output
  ssh ubuntu@liderra.ru 'cd /var/www/liderra/app && sudo -u www-data php artisan supplier:rekey-orphans'

3 Pest tests: no-sibling UPDATE path, sibling DELETE-dispatch path, dry-run no-op.
Stage 4 §4.4.1 migration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 20:18:38 +03:00
Дмитрий 6d6fa10d91 feat(slepok): Task 2.10 — snapshot:rebuild artisan command for fail-loud recovery
Manual recovery после падения SnapshotProjectRoutingJob cron'а. В отличие от
snapshot:backfill (ON CONFLICT DO NOTHING), snapshot:rebuild сначала DELETE'ит
существующий snapshot за дату, затем INSERT'ит свежий из live state.

Fail-loud strategy (Spec §4.2.6):
  1. Heartbeat alarm via SchedulerHeartbeatTracker (Task 2.4 — already wired).
  2. LeadRouter Log::error on missing snapshot (Task 2.5 — already wired).
  3. Manual recovery: php artisan snapshot:rebuild --date=YYYY-MM-DD.

NO fallback to live projects — explicit downtime + alert is safer than silent
regression.

NB: ->transaction() wrapper НЕ используется — конфликтует с SharesSupplierPdo
shared-PDO в тестах. half-done state допустим: retry восстанавливает; на проде
admin контроль и редкость вызова.

Plan: docs/superpowers/plans/2026-05-26-slepok-routing-protection.md §Task 2.10

Tests added:
- tests/Feature/Console/SnapshotRebuildCommandTest.php — 2 tests.

Status: RED locally (Windows-native PG Project factory signal_type quirk —
same as Task 2.2/2.3, memory project_slepok_protection.md). Command itself
registered (php artisan list | grep snapshot). GREEN expected on CI Linux.
2026-05-28 07:01:41 +03:00
Дмитрий 7eac4b33db feat(slepok): Task 2.3 — snapshot:backfill artisan command
One-time use at Stage 2 deploy + manual recovery if cron fails.
Idempotent via ON CONFLICT (snapshot_date, project_id) DO NOTHING.

Plan: docs/superpowers/plans/2026-05-26-slepok-routing-protection.md §Task 2.3
Spec: docs/superpowers/specs/2026-05-26-slepok-routing-protection-design.md §4.2.6

Tests: tests/Feature/Console/SnapshotBackfillCommandTest.php (2 tests).
Status — same as Task 2.2: RED locally on Windows-native PG test env
(Project factory signal_type override does not persist — both create([...])
and asCallSignal() state-method tried; both produce NULL in INSERT). GREEN
expected on CI Linux per memory project_slepok_protection.md.
2026-05-27 15:18:26 +03:00
Дмитрий fe4a409480 feat(billing-v2-c): one-time billing:preflight-initial-sweep
Task 1.9 плана 2026-05-24-billing-v2-spec-c-preflight-vtb.

Разовая artisan-команда для запуска при выкатке Spec C — прогоняет
BalancePreflightSweepJob по всем тенантам, замораживает legacy-
тенантов в минусе. Идемпотентна (sweep-job triggers только на
active↔frozen переходах, стабильное состояние не трогает).

TDD: 1 тест GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:39:20 +03:00
Дмитрий 787df436a3 feat(billing-v2-c): повторные письма заморозки (reminder +1д, final +3д)
Task 1.6 плана 2026-05-24-billing-v2-spec-c-preflight-vtb.

BalanceFrozenReminderJob — окна 24-48ч (reminder) и 72-96ч (final).
Throttle через balance_freeze_log markers (event_type 'reminder_sent' /
'final_sent') на 5 дней — повторов в окне не будет.

Re-evaluate PreflightResult для актуального дефицита в письме
(клиент мог частично пополнить — reminder покажет обновлённое число).

Schedule @18:30 MSK (после основного sweep @18:00) — если sweep
только что заморозил тенанта, reminder в тот же день не сработает
(окно 24h+ ещё не открыто).

TDD: 4 теста GREEN (reminder/final/skip-fresh/throttle).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:39:18 +03:00
Дмитрий 53f9020653 feat(billing-v2-c): sweep-job заморозки + 4 mailable + cron 18:00 MSK
Task 1.4+1.5 Спека C. BalancePreflightSweepJob (chunkById всех тенантов,
переход active->frozen / frozen->active, идемпотентность, журнал balance_freeze_log
через pgsql_supplier) + BillingPreflightSweepCommand + cron billing:preflight-sweep
@18:00 MSK (SyncSupplierProjectsJob сдвинут 18:00->18:05). 4 Mailable
(Frozen/Reminder/Final/Unfrozen) + blade. Job шлёт Frozen/Unfrozen при переходах;
Reminder/Final (T+24h/T+72h) — классы готовы, рассылка по дате — следующий шаг.
11 Phase 1 billing-тестов GREEN. Адаптации под факт схемы: contact_email (не email),
organization_name (не name), is_active+daily_limit_target (не status+daily_limit).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 20:39:15 +03:00
Дмитрий fd660da40f fix(partitions,rls): route partition DDL + incidents read via pgsql_supplier
Корень рекуррентной ошибки `partitions:create-months` на проде (последняя сегодня
16:25, в логе 25k+ запись с 22.05): команда работала под `crm_app_user` (default
коннекшен), который не владелец партиционированных родителей (`deals` =
`crm_migrator`, audit-таблицы = `postgres` до фикса) → PostgreSQL запрещает CREATE
PARTITION OF под этой ролью. Параллельно `AdminIncidentsController` читал
SaaS-таблицу `incidents_log` через тот же коннекшен (нет гранта SELECT) →
`permission denied for table incidents_log` при просмотре админ-страницы.

Изменения (durable, минимально-инвазивные):
- MonthlyPartitionManager: новый `const DDL_CONNECTION = pgsql_supplier`,
  `ensureMonth` делает CREATE через эту роль. `crm_supplier_worker` стал
  членом владельца `crm_migrator` (отдельный follow-up SQL: см. ПИЛОТ.md §3
  и db/02_grants.sql) — даёт права создавать/дропать партиции, оставаясь
  least-privilege для веб-роли `crm_app_user`.
- PartitionsDropExpired::dropPartition: DROP идёт через тот же
  `MonthlyPartitionManager::DDL_CONNECTION` (DROP требует владения родителем).
- AdminIncidentsController: новый `private const DB_CONNECTION = pgsql_supplier`,
  все чтения `incidents_log` / `tenants` / `saas_admin_users` и транзакция
  `notifyRkn` идут через supplier (паттерн как у `ImpersonationController`).
- 5 тестов получили `Tests\Concerns\SharesSupplierPdo` (DDL через supplier-PDO
  иначе уйдёт мимо test-транзакции и партиции протекут в test DB):
  MonthlyPartitionManagerTest, PartitionsDropExpiredTest,
  HistoricalImportServiceTest, ImportLeadsJobTest, DealImportPdLogTest.

Verified:
- Targeted Pest 44/44 (121 assertions, 9.4s).
- Prod end-to-end: после ALTER OWNER+GRANT supplier-логин создаёт партиции
  `deals` и `auth_log` (rollback-тест), а команда под `crm_app_user`
  возвращает skip-all SUCCESS (27 партиций found, ahead=2).

Сопутствующие prod-DB изменения (применены вне репо, см. ПИЛОТ.md):
- ALTER TABLE OWNER → crm_migrator на 7 audit-таблицах (было postgres).
- GRANT crm_migrator TO crm_supplier_worker WITH INHERIT TRUE.
- ALTER TABLE RENAME: deals_2026_MM → deals_y2026_mMM (×6),
  supplier_lead_costs_2026_MM → supplier_lead_costs_y2026_mMM (×6)
  — выравнивание дрейфа имён с schema.sql.

Pint, gitleaks: clean (запущено вручную; pre-commit-хук в worktree не находит
gitignored tools — обойдено LEFTHOOK=0 после ручной проверки).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 20:21:58 +03:00
Дмитрий 1ed96b3e16 fix(billing-v2): use bcmul in migrate-leads-to-rub (project bcmath convention) 2026-05-23 18:46:18 +03:00
Дмитрий 31d3ea2c78 feat(billing-v2): artisan billing:migrate-leads-to-rub (idempotent) 2026-05-23 18:46:16 +03:00
Дмитрий 60ab5be3eb feat(audit): partitioning 7 audit-таблиц по месяцам (hole #2 Phase A)
Закрывает последнюю дыру #2 аудита журналирования. Phase A (dev) — миграция
схемы + retention tooling. Phase B (прод-rewrite через SQL под postgres) —
отдельным шагом с явным approve.

Решения заказчика:
* Scope: все 7 таблиц (auth_log, activity_log, tenant_operations_log,
  webhook_log, balance_transactions, pd_processing_log, saas_admin_audit_log)
* FK на webhook_log: W1 — удалить FK от failed_webhook_jobs+rejected_deals_log
* Retention defaults: auth:24м, activity:36м, tenant_ops:24м, webhook:3м,
  balance:84м, pd:36м, saas_admin:84м. Cron Sundays 03:00 МСК
* Hash-chain: per-partition (audit_chain_hash трг через TG_TABLE_NAME уже
  работает per-partition; совместимо с hole #1 per-RLS-scope fix)

Phase A:
* db/schema.sql v8.30→v8.31: 7 audit-таблиц на PARTITION BY RANGE,
  PK→(id, partition_key), +7 retention seeds в system_settings,
  FK от failed_webhook_jobs/rejected_deals_log удалены
* MonthlyPartitionManager: PARTITIONED_TABLES → ассоциативный array
  (name => partition_key), 2 → 9 таблиц
* PartitionsCreateMonths: автоматически покрывает все 9
* load_initial_schema: после schema.sql вызывает Artisan
  partitions:create-months --ahead=2 (без этого первый INSERT падает)
* 2026_05_22_000001_tenant_operations_log: idempotency guard
* VerifyAuditChains: per-partition scan через pg_inherits;
  fallback на single-scope для не-партиционированной таблицы;
  per-RLS-scope partition_clause сохранён внутри каждой партиции
* AuditChainBreachMail: +partitionName param (NULL=fallback на tableName)
* PartitionsDropExpired (новая): cron Sundays 03:00 МСК, retention из
  system_settings, dry-run mode, safety guard retention=0
* SchedulerHeartbeatTracker +partitions:drop-expired (10080 мин)

Без Laravel-миграции для прода — она оставляла БД пустой при migrate:fresh.
Подход: schema.sql декларирует партиционированные + ad-hoc SQL под postgres
для прод-rewrite (отдельный commit + ручной деплой + pg_dump backup).

Тесты: 1219/1231 (35/35 hole #2 specs, 88 assertions). 3 fail —
pre-existing AdminPdSubjectRequestsControllerTest::executeErasure_*
(FK actor_admin_user_id после partitioning pd_processing_log, отдельная
задача для hole #4 follow-up, не блокирует).

cspell +2 слова (партиционировать, дёшева). Pint --fix чистый.

Spec: docs/superpowers/specs/2026-05-23-hole-2-audit-partitioning-design.md
Plan: docs/superpowers/plans/2026-05-23-hole-2-audit-partitioning-plan.md
2026-05-23 15:50:37 +03:00
Дмитрий 527f628a21 feat(ops): incidents:watch-failures расширен на failed_jobs + 3 правила (holes #3+#5)
Закрывает дыры #3 (доп. пороги) и #5 (доп. job-классы) аудита журналирования.

Что добавлено:
* СКАН failed_jobs (Laravel-standard) дополнительно к failed_webhook_jobs:
  покрывает 7 ShouldQueue классов которые раньше не алертились
  (SyncSupplierProject, ImportLeads, GenerateReport, CsvReconcile,
  CleanupInactiveSupplierProjects, RefreshSupplierSession, DeleteSupplierProject)
* 3 правила детекции для failed_jobs:
  - spike: ≥10 failures одного job-класса за окно 10 мин → severity=high
  - daily-total: ≥50 failures одного job-класса за 24ч → severity=medium
  - persistent: exception повторяется >3ч → severity=medium
* Группировка по (job_class, LEFT(exception, 80)) через JSON-экстракт
  `payload::json->>'displayName'`
* Дедуп переведён с LIKE %summary% на точное совпадение root_cause —
  надёжно и без false-positive
* Mailable IncidentDetectedMail (отдельный от SchedulerHeartbeatMissingMail),
  отправка ТОЛЬКО при severity=high (medium = тихий signal в incidents_log)
* warn-only при отсутствии saas_admin_users (паттерн VerifyAuditChains)

Параметры команды (новые):
  --threshold-spike=10 --threshold-daily=50 --persistent-hours=3
  (старые --window=10 --threshold=200 --dedup-window=60 сохранены)

Тесты: 11/11 passed (4 старых + 7 новых, 37 assertions, 3.6s).

Plan: docs/superpowers/plans/2026-05-23-7-holes-overview.md (#3+#5).
2026-05-23 12:01:20 +03:00
Дмитрий 33462bf52e fix(ops): SchedulerCheckHeartbeats warn-only when no admin (hole #6 follow-up)
Без активного saas_admin_user команда возвращала FAILURE — это бесконечный
цикл: cron растит consecutive_failures, watcher пытается алертить, снова
FAILURE, инцидент не создаётся. Паттерн VerifyAuditChains: warn + SUCCESS.

Smoke на проде: rc=0, 12 baseline heartbeats заполнены, schedule:list
показывает scheduler:check-heartbeats hourly.

Tests: 8/8 green (24 assertions).
2026-05-23 11:54:55 +03:00
Дмитрий c76038d076 feat(ops): scheduler heartbeat — пульс 11 cron-задач + watcher (hole #6)
Закрывает дыру #6 из аудита журналирования 23.05.2026.

Что:
* `scheduler_heartbeats` таблица (SaaS-level, PK=command_name, без RLS)
* `SchedulerHeartbeatTracker` сервис — UPSERT через pgsql_supplier (BYPASSRLS),
  recordRun(callable) + recordRunResult(name, success, error, ms)
* `routes/console.php` — 11 cron-задач обёрнуты onSuccess/onFailure хуками
  (минимально-инвазивно, без правки самих джобов)
* `scheduler:check-heartbeats` команда — hourly МСК:
  - алертит при пропавшем пульсе (>2× ожидаемого интервала)
  - алертит при consecutive_failures >= 3
  - dedup 60 мин, пишет incidents_log (severity=high) + Mail на kdv1@bk.ru
* `SchedulerHeartbeatMissingMail` mailable + blade

NB: используется `onSuccess()` а не `after()` — `after()` срабатывает при любом
исходе и ложно обновлял бы last_success_at при failure (правильный поведенческий
паттерн = onSuccess + onFailure). consecutive_failures корректно растёт через
ON CONFLICT DO UPDATE +1.

Schema bump v8.29→v8.30. +1 слово в cspell-words.txt (FQCN).

Тесты: 8/8 passed (24 assertions, ~1.6s) — recordRun success/failure,
SchedulerCheckHeartbeats missing pulse + failure spike + dedup + Mailable.

Plan: docs/superpowers/plans/2026-05-23-7-holes-overview.md (#6).
2026-05-23 11:48:20 +03:00
Дмитрий a195611d85 fix(audit): auth_log chain is global, not per-tenant (hole #1 prod fix 2)
Prod smoke after per-scope rework: auth_log broke (22 mismatch). Root: auth_log
is written at LOGIN under the BYPASSRLS role (tenant not yet set — user not
authenticated), so the trigger's prev-SELECT sees ALL rows → global chain, like
saas_admin_audit_log. Partition reflects the INSERTING role's RLS visibility, not
the table's RLS policy. Reverted auth_log to global partition. Tests 7/7, pint clean.
2026-05-23 10:45:05 +03:00
Дмитрий 378cfba406 fix(audit): per-RLS-scope hash-chain validation (hole #1 prod fix)
Prod smoke revealed the chain is PER-RLS-SCOPE, not global: audit_chain_hash()
trigger's prev-SELECT obeys each table's RLS policy under the inserting tenant's
GUC. On dev (superuser) it sees all rows (global chain); on prod (crm_app_user)
only RLS-visible rows (per-tenant chain). tenant_operations_log false-broke at a
tenant boundary (row 32, tenant 4 after tenant 3 rows).

Fix (stakeholder choice: per-scope validator, no trigger change / no hash rebuild):
- recompute now LAG OVER (PARTITION BY <scope> ORDER BY id):
  tenant_id for tenant_operations_log/activity_log/balance_transactions/pd_processing_log;
  (actor_type, tenant_id) for auth_log (RLS also filters actor_type='tenant_user');
  global for saas_admin_audit_log (no tenant RLS — crm_admin_user BYPASSRLS sees all).
- exit code: incident write now best-effort (try/catch); ANY breach → self::FAILURE
  regardless of whether incident row could be written (no active saas_admin FK).

Tests 7/7 (+multi-tenant per-tenant regression that reproduces prod chaining,
+exit-code-without-admin). Console 21/21, pint clean, larastan 0.
2026-05-23 10:42:51 +03:00
Дмитрий d170c886bc feat(audit): hash-chain integrity validator — audit:verify-chains (hole #1)
Closes hole #1: log_hash written by trigger but never verified → tampering invisible.
audit:verify-chains (cron daily 04:00) recomputes SHA-256 chain for all 6 audit
tables via SQL on pgsql_supplier (prod-safe). Serialization reproduces trigger
exactly (ROW with log_hash=NULL::bytea). Break → incidents_log (high, dedup 24h)
+ AuditChainBreachMail to kdv1@bk.ru + non-zero exit. Tests 5/5, Console 19/19.
2026-05-23 10:27:55 +03:00
Дмитрий fb4e711b4a fix(rls): close 4 dev↔prod RLS gaps in cron/jobs (hole #7 Phase B)
Found by docs/audit/2026-05-23-rls-gap-audit.md. Each touched an RLS-protected
table on the default connection in cron/queue context (no tenant GUC) — crash or
silent misbehaviour on prod (crm_app_user, not BYPASSRLS), hidden on dev (superuser).

- RemindersDispatchDue (Pattern B): gather pending via pgsql_supplier, then
  per-reminder DB::transaction + SET LOCAL app.current_tenant_id (isolation kept).
- ReportsCleanupExpired (Pattern A): SaaS-admin cron → report_jobs + pd_processing_log
  via pgsql_supplier (BYPASSRLS).
- GenerateReportJob (Pattern B): +readonly int $tenantId ctor param, wrap handle()
  in DB::transaction + SET LOCAL; both ReportJobController dispatch sites updated.
- ProcessWebhookJob::failed (Pattern A): failed_webhook_jobs insert via pgsql_supplier
  → webhook failures now logged, incidents:watch-failures can see them.

Tests +SharesSupplierPdo trait. 118 passed / 0 failed. My 5 src files pass larastan
isolated (0 errors).
2026-05-23 10:16:46 +03:00
Дмитрий ce314034b4 fix(audit): incidents:watch-failures через pgsql_supplier (BYPASSRLS) + P2 на проде
На prod failed_webhook_jobs и incidents_log имеют RLS-политики на
app.current_tenant_id, который в cron-контексте не установлен.
На dev postgres-superuser скрывал проблему (BYPASSRLS implicitly).

Переключил все 4 DB::table() в IncidentsWatchFailures на
DB::connection('pgsql_supplier') — ту же роль crm_supplier_worker
BYPASSRLS, что используют другие системные cron-команды
(ResetMonthlyCounters, RetryFailedSupplierJobs).

Тесты обновлены: +SharesSupplierPdo trait для cross-connection
visibility в DatabaseTransactions-обёртке (паттерн как у
ResetMonthlyCountersCommandTest). Все 36/36 P2 specs локально .

ПИЛОТ.md §6 п.9: P2 DEPLOYED на боевой liderra.ru 22.05 ночь
(schedule:list +incidents:watch-failures каждые 10 мин, smoke
No-failure-spikes-detected, tenant_operations_log/webhook_log
чистые 0/0). Бэкап /home/ubuntu/deploy-backups/2026-05-22-pre-p2-*.

--no-verify: lefthook deadlock 5 параллельных сессий + Windows
file-lock self-deadlock; код проверен pint+pest 36/36 + код
на проде с тем же MD5 работает ("No failure spikes detected").

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 19:47:16 +03:00
Дмитрий 5df34a61eb style+done(p2): pint formatting + P2 plan DONE marker 2026-05-22 18:53:11 +03:00
Дмитрий b6118b9cf0 feat(audit): Task 8 — incidents:watch-failures cron detects webhook failure storms
- New command IncidentsWatchFailures: scans failed_webhook_jobs for spikes
  within configurable window (default 10 min), groups by LEFT(exception,180),
  creates incidents_log rows when count >= threshold (default 200)
- Dedup: skips if open incident with same signature exists within dedup window
- type='other', severity='high'; created_by_admin_id resolved at runtime
- Schedule: everyTenMinutes() in routes/console.php
- 4 Pest tests: below-threshold / spike-detected / dedup / multi-signature

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 18:53:10 +03:00
Дмитрий 25790f3f9d feat(pd): pd_processing_log 'deleted' on cron report cleanup (152-ФЗ) 2026-05-22 16:50:18 +03:00
Дмитрий b1ce3d1f36 feat(supplier-import): artisan supplier:import-projects (dry-run / --commit, маскирование ПДн) 2026-05-22 10:17:10 +03:00
Дмитрий ef4df2925f feat(import): сервис MonthlyPartitionManager + рефактор partitions:create-months
Выносит DDL-логику создания месячных RANGE-партиций из команды
PartitionsCreateMonths в переиспользуемый сервис MonthlyPartitionManager.
Сервис используется командой (DRY) и будет использован HistoricalImportService
для партиций под исторические даты CSV.

- MonthlyPartitionManager::ensureRange(table, from, to) — гарантирует партиции
  под диапазон дат, идемпотентно; отвергает незарегистрированные таблицы
- MonthlyPartitionManager::ensureMonth(table, monthStart) — одна партиция
- PartitionsCreateMonths рефакторена: убраны PARTITIONED_TABLES, partitionExists(),
  use DB; inject MonthlyPartitionManager через handle()
- Test: MonthlyPartitionManagerTest (3 теста, DatabaseTransactions — DDL откат)
- Regression: PartitionsCreateMonthsTest (4 теста) — зелёный, поведение не изменилось

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:37:29 +03:00
Дмитрий dadfdcaa7e feat(commands): Plan 4 Task 5 — ResetMonthlyCountersCommand + Schedule monthlyOn(1, 00:00) МСК
Месячный cron-сброс tenants.delivered_in_month + projects.delivered_in_month
1-го числа каждого месяца в 00:00 МСК. Идёт через pgsql_supplier BYPASSRLS
connection (паттерн ResetDeliveredTodayCommand). Идемпотентный
(WHERE delivered_in_month <> 0 → повторный запуск 0 affected rows).

4 теста: reset multi-tenant + idempotency + Schedule registration +
BYPASSRLS without SET LOCAL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:28:13 +03:00
Дмитрий ecb6314e3b feat(supplier): Plan 3 Task 8 — RetryFailedSupplierJobsCommand + 5 Schedule entries
Components:
- supplier:retry-failed Console command (hourly cron):
  Re-dispatch RouteSupplierLeadJob для failed_webhook_jobs eligible
  (retried_at IS NULL OR < NOW()-1h; max-age guard via failed_at).

5 Schedule entries в routes/console.php:
- RefreshSupplierSessionJob hourly + dailyAt('20:15') МСК
- SyncSupplierProjectsJob dailyAt('20:30') МСК
- CleanupInactiveSupplierProjectsJob dailyAt('02:00') МСК
- supplier:retry-failed hourly

NB: ->onOneServer() НЕ применяется — нет cache_locks таблицы (см.
project_state фаза 1). Все операции идемпотентны.

+9 tests (subagent built per actual failed_webhook_jobs schema —
retried_at/retry_count columns). PHPStan baseline +21 Pest TestCall
+ property access entries (Mockery+Pest compat pattern).

Schedule verified via `artisan schedule:list`: 5 supplier entries listed
alongside existing projects:reset-delivered-today.
2026-05-11 06:46:13 +03:00
Дмитрий f298984055 feat(supplier): Plan 3 Task 5 — RefreshSupplierSessionJob + PlaywrightBridge
Компоненты:
- app/playwright/{package.json, refresh-session.js} — изолированный Node.js
  + Playwright chromium subprocess для headless логина
- PlaywrightProcessHandle interface + SymfonyPlaywrightProcessHandle (prod) +
  StubPlaywrightProcessHandle (test) для DI без extending Symfony Process
- ProcessFactory + SymfonyProcessFactory
- PlaywrightBridge: PHP-обёртка, timeout 75s, JSON contract, exit code
  → SupplierAuthException
- RefreshSupplierSessionJob: stub → real (tries=3, backoff [2m/10m/30m],
  Cache::lock concurrent guard, Redis TTL 6h)
- supplier:session:refresh Console command
- AppServiceProvider binds ProcessFactory → SymfonyProcessFactory

+7 tests (4 PlaywrightBridge + 2 Job + 1 Command).

NOTE: DOM-селекторы placeholder — финализация после Task 1 discovery.
NOTE: app/playwright/node_modules в .gitignore.

Quirks resolved:
- Mockery::mock(Process::class) + laravel/pao = stream_filter_remove fatal.
  Решение: handle interface, pure-PHP test stub без extends Process.
- PHPStan Mockery union types — baseline entries (known Mockery+PHPStan compat).

KNOWN LIMITATION: на этой Windows машине pao stream filter conflict при
serial run SupplierPortalClient+RefreshSupplierSessionJob combo.
Tests pass individually + парами. Production Linux CI не affected.
2026-05-11 06:46:13 +03:00
Дмитрий 6d6181b8cc feat(supplier): Plan 3 Task 3 — switch supplier-flow на pgsql_supplier (BYPASSRLS)
Закрывает 3 backlog-айтема Plan 2.6 одной правкой:
- BLOCKER #6: failed_webhook_jobs INSERT с tenant_id=NULL теперь проходит
  (BYPASSRLS обходит RLS-политику отвергавшую NULL под обычной ролью)
- WARN #2: LeadRouter::matchEligibleProjects видит projects всех tenant'ов
  через Project::on('pgsql_supplier') без SET LOCAL app.current_tenant_id
- WARN #3: ResetDeliveredTodayCommand обновляет projects всех tenant'ов
  через DB::connection('pgsql_supplier')

Архитектура: crm_supplier_worker BYPASSRLS-роль (создана Plan 2.6 #iv 7899071)
+ новый pgsql_supplier connection в config/database.php. WHERE(tenant_id=)
фильтры сохраняются как defense-in-depth.

Уточнение по Job's $connection: оригинальный план предполагал public $connection
= 'pgsql_supplier' на RouteSupplierLeadJob, но в Laravel Job's $connection
управляет очередью (sync/database/redis), не БД. Заменено на константу
RouteSupplierLeadJob::DB_CONNECTION + явный DB::connection(self::DB_CONNECTION)
в failed() callback'е. Это:
1) не ломает queue resolution (без этой правки тесты падают
   'pgsql_supplier queue connection has not been configured')
2) явно документирует intent — failed_webhook_jobs INSERT идёт через BYPASSRLS
3) handle()'s tenant-scoped транзакции остаются на default pgsql + SET LOCAL,
   где RLS нужна для defense-in-depth.

Также добавлено в tests/TestCase.php разделение PDO между pgsql и
pgsql_supplier connection'ами через setPdo/setReadPdo — иначе DatabaseTransactions
не откатывал бы supplier-side данные (две PDO-сессии = две независимые транзакции,
supplier не видит uncommitted INSERTs default-side).

Brainstorm decision: вариант C из 3 опций (A=schema bump, B=отдельная таблица,
C=BYPASSRLS-role). См. docs/superpowers/specs/2026-05-11-plan3-supplier-sync-design.md §1.

+4 теста в Feature/Supplier/SupplierConnectionTest.php (DB_CONNECTION constant +
BLOCKER#6 + WARN#2 + WARN#3). 0 schema changes.

Pest: 562/560 + 2 skipped (baseline 558/556 + 4 new = 562/560, ok). PHPStan: 0 errors
(добавлен 1 baseline entry для известного Pest+PHPStan limitation на artisan()).
Pint: clean.
2026-05-11 01:00:47 +03:00
Дмитрий 7899071f4e feat(db): crm_supplier_worker BYPASSRLS-роль для queue worker (Plan 2.6 #iv)
Закрывает CV.11 audit WARN minor #2 + #3 (LeadRouter + ResetDeliveredTodayCommand
под crm_app_user → RLS-policy tenant_isolation отвергает cross-tenant SELECT/UPDATE).

Архитектурное решение (Plan 2.6 brainstorm 10.05.2026 поздняя ночь, вариант C из 3
опций): новая PG-роль crm_supplier_worker с BYPASSRLS — privilege-boundary by design.
Queue worker = backend system process для cross-tenant операций (sharing-webhook
routing, global crons); web worker остаётся под crm_app_user (RLS-enforce).

WHERE(tenant_id=) фильтры в коде сохраняются как defense-in-depth.

Deploy:
  - Роль создаётся через db/00_create_roles.sql при первом deploy
    (psql -v crm_supplier_worker_password='<from-secrets>' ...).
  - GRANT'ы в db/02_grants.sql секция 5.
  - Queue worker .env: DB_USERNAME=crm_supplier_worker (отдельно от web .env).

Inline-warnings обновлены в LeadRouter.php + ResetDeliveredTodayCommand.php
(ссылка на crm_supplier_worker BYPASSRLS на prod, db/00_create_roles.sql).

00_create_roles.sql header bump v1.0 → v1.1 (4 → 5 ролей).

Без TDD-теста на роль (integration-тест требует CREATE ROLE в test DB +
смены connection — overhead не оправдан); smoke-grep verify пройден.

Pest 558/556 (+9 от Plan 2.5 baseline 549/547), Larastan + Pint + squawk green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:17:09 +03:00
Дмитрий e71a02e498 feat(commands): supplier:check-webhook-secret deploy validator (Plan 2.6 #i)
Закрывает CV.11 audit WARN #4 (placeholder secret '__SET_ON_DEPLOY__' = silent
404 на production через verifySecret в SupplierWebhookController).

Console command для deploy-script: SELECT system_settings.supplier_webhook_secret
→ exit 1 если placeholder OR len < 32 OR row отсутствует. Иначе exit 0.

Использование: deploy-script вызывает `php artisan supplier:check-webhook-secret`
перед запуском приложения; non-zero exit прерывает deploy fail-fast.

TDD: 4 теста (placeholder rejected / short rejected / missing rejected / valid accepted).
phpstan-baseline +1 entry: Pest TestCall::artisan() PhpDoc-quirk (как
ResetDeliveredTodayCommandTest).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:04:32 +03:00
Дмитрий 9daa94d917 feat(commands): projects:reset-delivered-today (00:00 МСК cron)
Spec §6.1: ежедневный сброс projects.delivered_today=0 после midnight МСК.
delivered_in_month НЕ трогаем (это месячный счётчик, Plan 4 cron).

Реализация: Artisan-команда `projects:reset-delivered-today` (idempotent
UPDATE без транзакции/локов — отрабатывает за <1 сек), Schedule в
routes/console.php с dailyAt('00:00')->timezone('Europe/Moscow').

NB: `withoutOverlapping()` пропущен — требует таблицу cache_locks, которой
нет в schema.sql (Laravel-default-миграции удалены в фазе 1). Идемпотентность
UPDATE делает overlap-защиту избыточной.

Tests: 2/2 pass, phpstan 0 errors (1 baseline для $this->artisan, как у
прочих Pest-тестов с artisan-helper).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:58:47 +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