Commit Graph

66 Commits

Author SHA1 Message Date
Дмитрий 681e6ca631 feat/billing: F/J — единый расчёт замков + пополнение/пересчёт снимают оба замка
- D2: requiredLeadsForTomorrow переведён на полный лимит, откат share-aware R-19 [решение владельца]
- B/D3: пополнение снимает клиентскую заморозку И блоки проектов вместе, политика всё-или-ничего
- F/J/D6: вечерний пересчёт 18:00 снимает блоки проектов у незаморожённых; общий ProjectBlockReleaseService; иерархия заморозка > блок
- fix: balance_freeze_log INSERT переведён на главное соединение — межсессионный self-lock с FOR UPDATE топапа [найден живым прогоном, pg_blocking подтвердил; в тестах маскировался SharesSupplierPdo]
- spec + plan в docs/superpowers

138/138 биллинг-тестов GREEN. Pint чисто. Живьём B+F подтверждены на докалке. На прод НЕ катилось.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 14:58:44 +03:00
Дмитрий 075d12bef3 feat(billing): provenance — saas_transactions.balance_transaction_id, связка оплата->журнал
Закрыт хвост из billing-audit: webhook при зачислении пишет id строки ledger
в saas_transactions.balance_transaction_id (жёсткая прослеживаемость). Колонка
BIGINT nullable без FK (balance_transactions партиционирована). schema.sql v8.52
+ миграция 2026_06_22_170000 (guarded) + CHANGELOG. Тест проверяет связку. 115/115.
2026-06-22 22:30:36 +03:00
Дмитрий 44506e6f53 feat(billing): Eloquent-модели legal_entities/payment_gateways/saas_transactions 2026-06-22 20:26:47 +03:00
Дмитрий a119aba822 fix(G7-B): stray не-lpimp Bearer на sanctum-роуте → чистый 401 вместо 500 (нет таблицы PAT) 2026-06-19 17:35:53 +03:00
Дмитрий aaeb396b1f feat(G7-B): guard impersonation + envelope машинного ключа на рабочих группах кабинета
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 16:44:42 +03:00
Дмитрий 0375c2a35a feat(G7-B): verify — session-takeover целевого юзера + выдача машинного ключа 2026-06-19 16:35:03 +03:00
Дмитрий 448633e59c feat(G7-A): модель SupportRequest
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 14:54:25 +03:00
Дмитрий ef28196082 fix(stan): Larastan-долг G1/G6 = 0 ошибок (реальные баги — починены, не спрятаны)
Продуктовый код (фиксы, не baseline): TenantRequisites+SupplierLead — явные @property (ide-helper:models пропускал модели); DealsController V1 — лишний ?-> на non-null received_at; ScrubPii — guard instanceof Monolog. Тест-код: ImitationTestCase @param int; findByInn return-type. Baseline перегенерён — в нём ТОЛЬКО ложноположительные (Pest TestCall + защитный ?-> на nullable first() в debug-строках ScenarioBC), 0 продуктовых подавлений (проверено диффом). composer stan: 0.

NB: столбцы lead-region (dadata_qc/phone_operator/region_source/resolved_subject_code) есть в БД, но отсутствуют в db/schema.sql — отдельный дрейф схемы.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 12:40:00 +03:00
Дмитрий 5df354c39c feat(G3): удалить контроллер/роуты/модель/команду/письмо напоминаний
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 11:37:57 +03:00
Дмитрий 60f76d5919 feat: G1/SP2 реквизиты клиента + ИНН по DaData + гейт первого проекта
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:25:23 +03:00
Дмитрий 9a661401a2 feat: G1/SP1 самозапись клиента с подтверждением почты 6-значным кодом
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 19:33:33 +03:00
Дмитрий 4022a71bac 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
Дмитрий 381ce39142 chore(incident-followup): close 4 tails from 29.05 disk-full incident
ремонт: incident-followup cleanup batch — 4 хвоста

1. Larastan baseline regenerated (was 161 errors pre-existing IDE helper drift)
2. Deptrac Mail: [Model, Service] + ADR-005 amend (was 4 pre-existing violations)
3. PG logrotate config in setup-logrotate.yml
4. F1 6 mismatches — RCA updated (algorithm divergence trigger global vs verify per-tenant)

+3 cspell words: notifempty, missingok, верифицируется.

Ref: docs/incidents/2026-05-29-disk-full-pg-recovery.md §4-5
2026-05-29 14:45:28 +03:00
Дмитрий 5859b2bf7e feat(billing): R-19 — share-aware requiredLeadsForTomorrow
Tenant::requiredLeadsForTomorrow() previously summed raw daily_limit_target of
active projects, overcharging preflight when a tenant shared a call/site signal
with other tenants. Supplier caps the group at max(max(limits), ceil(Σ/3)) and
splits it across all clients on the same signal_identifier, so a single tenant's
real share is typically much smaller than its raw limit.

  group_limits = limits of all is_active projects sharing
                 (signal_type, agnostic signal_identifier/sms_sender+keyword)
  group_order  = max(max(group_limits), ceil(Σ group_limits / 3))
  tenant_share = ceil(group_order × (project_limit / Σ group_limits))

Legacy webhook projects (signal_type=null — no supplier sharing) still count
their full limit (regression-protected by existing 'sums daily_limit_target' test).
Empty groupLimits edge → conservative full-limit fallback (cross-conn race).

3 Pest tests: single project (legacy passthrough), 3-tenant share discriminator
(10→4), legacy webhook regression. Stage 4 §4.4.3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 20:05:53 +03:00
Дмитрий e0603787bc feat(billing-v2-c): Tenant::requiredLeadsForTomorrow + cast флагов заморозки
Task 1.3 Спека C. Tenant: +frozen_by_balance_at (fillable+cast datetime) +
requiredLeadsForTomorrow() (sum daily_limit_target активных проектов). Project:
+preflight_blocked_at (fillable+cast). NB: фильтр по is_active (boolean) +
daily_limit_target — у projects нет колонок status/daily_limit (план поправлен
под факт схемы). 3 теста GREEN.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 20:39:14 +03:00
Дмитрий ac2860064f feat(project-model): fillable + cast paused_at as datetime 2026-05-26 11:17:05 +03:00
Дмитрий 4652741c79 docs(billing): Phase 3 cleanup — refresh orphan comments to live classes
After ProcessWebhookJob/WebhookReceiveController removal — обновлены 8
docblock/inline комментариев, ссылавшихся на удалённый код:

- DealController: ProcessWebhookJob → SupplierWebhookController/RouteSupplierLeadJob
- SupplierWebhookController: убрана legacy backward-compat note
- ImportLeadsJob: паритет с RouteSupplierLeadJob
- RouteSupplierLeadJob: убрана ссылка на ProcessWebhookJob-pattern
- NewLeadNotification mailable: триггер в RouteSupplierLeadJob
- FailedWebhookJob model: ссылка на RouteSupplierLeadJob::failed()
- SupplierLeadCost model: создаётся в LedgerService::chargeForDelivery
- CsvLeadsParser: паритет с RouteSupplierLeadJob парсером

Code-функциональность не затронута, только doc-rot fix.
2026-05-24 18:51:16 +03:00
Дмитрий 96f2181e07 refactor(webhook): Phase 3 Task 3.4 - delete RejectedDealsLog model (all callers removed in Phase 2) 2026-05-24 18:51:14 +03:00
Дмитрий 2a9dad8fef refactor(webhook): Phase 3 Task 3.0 - remove webhook_token from Tenant fillable/casts (factory+tests deferred: col still NOT NULL) 2026-05-24 18:51:13 +03:00
Дмитрий 7feacaa9e9 feat(billing-v2): supplier_lead_deliveries lock table (Spec B)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 20:44:52 +03:00
Дмитрий 8e05486083 feat(billing-v2): add BalanceTransaction::TYPE_MIGRATION + extend CHECK
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 18:46:08 +03:00
Дмитрий 07fec8af87 fix(pd): PdSubjectRequest::$connection = pgsql_supplier (hole #4 prod fix)
crm_app_user (default pgsql connection) не имеет INSERT/UPDATE прав на
pd_subject_requests — это SaaS-уровневая таблица. На проде Eloquent
PdSubjectRequest::create() падал с Insufficient privilege.

Фикс: protected $connection = 'pgsql_supplier' (BYPASSRLS, crm_supplier_worker)
— симметрично существующему контроллеру и сервису. Альтернатива (GRANT для
crm_app_user) размывает границу tenant-уровня (db/00_create_roles.sql).

Smoke прод: create через tinker → row.id=1, deadline_at +30 дней auto-trigger
сработал. Cleanup row выполнен.

Tests: 12/12 passed (67 assertions, 2.5s).
2026-05-23 12:27:57 +03:00
Дмитрий a3cd09da30 feat(pd): 152-ФЗ право на удаление — минимум (hole #4)
Закрывает дыру #4 аудита журналирования. Объём по выбору заказчика — МИНИМУМ:
 Админ-API + кнопка в админке для удаления ПДн субъекта
 Сервис анонимизации (users + supplier_leads + deals + webhook_log)
 Журнал факта удаления в pd_processing_log
 БЕЗ формы самообслуживания на стороне субъекта
 БЕЗ email-подтверждения
 БЕЗ 30-дневного SLA (trigger deadline_at уже в схеме)

Что добавлено:
* Eloquent-модель `App\Models\PdSubjectRequest` (таблица уже была в схеме)
* Сервис `App\Services\Pd\PdErasureService::eraseSubject()`:
  - cross-tenant через pgsql_supplier (BYPASSRLS)
  - транзакционно (rollback при ошибке)
  - users: email→erased-{id}@deleted.local, first_name→Удалено, last_name→null,
    phone→+7000{id}
  - supplier_leads: phone→+7000XXXXXXX, raw_payload→{erased:true}
  - deals: phone→+7000XXXXXXX, contact_name→Удалено (только если есть phone)
  - webhook_log: batched UPDATE по 500, raw_payload→{erased,erased_at}
  - pd_processing_log запись action=deleted за каждого user/lead с
    actor_admin_user_id (hash-chain audit_chain_hash триггером сам подписывает)
  - При requestId — pd_subject_requests SET status=completed, completed_at,
    response_text счёт
* Контроллер `AdminPdSubjectRequestsController`: index/show/store/executeErasure
* Маршруты под middleware(saas-admin): GET/POST /api/admin/pd-subject-requests,
  GET /{id}, POST /{id}/erase
* Vue: `AdminPdSubjectRequestsView` (Quiet Luxury, таблица + диалог создания +
  кнопка Анонимизировать для request_type=deletion); ESLint требует
  v-slot:[`item.X`]= вместо #item.X для динамических slot-имён с точкой
* Пункт меню в AdminLayout.vue + route /admin/pd-subject-requests

NB: реальная схема — users.first_name/last_name/phone/email; supplier_leads
имеет только phone (нет contact_*); deals имеет phone+contact_name (нет
contact_email); webhook_log JSONB. PdErasureService адаптирован под факт.

Тесты: 12/12 passed (63 assertions, ~2.6s) — index pagination, store +
deadline trigger (+30 дней), eraseSubject анонимизация user/lead/deal/log,
pd_processing_log запись, request status→completed, отклонение
не-deletion типов, gate saas-admin, InvalidArgumentException.

Plan: docs/superpowers/plans/2026-05-23-7-holes-overview.md (#4).
2026-05-23 12:21:21 +03:00
Дмитрий 12b7674601 refactor(projects): remove archive feature, drop archived_at column (schema v8.27) 2026-05-21 08:24:25 +03:00
Дмитрий d8ab9df4b8 feat(supplier): Project<->SupplierProject belongsToMany via pivot 2026-05-20 11:04:24 +03:00
Дмитрий 9f8482277d feat(supplier): deals.subject_code from supplier tag (v8.26) 2026-05-20 10:57:04 +03:00
Дмитрий 7ec9dd6061 feat(supplier): supplier_projects.subject_code + per-subject unique index (v8.26) 2026-05-20 10:45:02 +03:00
Дмитрий 0c3ff35485 test(supplier): cover FailoverProjectChannel tier-3 escalation + transient bypass
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:31:11 +03:00
Дмитрий eeda9d4214 feat(supplier): supplier_manual_sync_queue table (Tier 3 queue)
SaaS-level (без tenant_id, без RLS, как supplier_csv_reconcile_log).
+3 CHECK (platform/operation/status), +2 индекса, +2 FK
(project_id→projects CASCADE, resolved_by_user_id→users SET NULL).

Миграция через DB::unprepared (PG prepared statement не разрешает multi-SQL).
schema.sql bumped v8.24 → v8.25 (64 base tables / 121 indexes / 40 RLS).
SchemaDeltaTest обновлён под новые метрики (63→64 tables, 119→121 indexes).

§15.2 pre-flight: rebase на origin/main 831573d выполнен до коммита.
Spec §4.5. Task 3 of 12. Регрессия: schema+delta тесты 11/11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:55:06 +03:00
Дмитрий c847c02441 merge: C9 — Plan 6 регионы субъект-уровня в портал
# Conflicts:
#	app/tests/Feature/Plan4/Schema/SchemaDeltaTest.php
#	db/CHANGELOG_schema.md
#	db/schema.sql
2026-05-17 09:30:21 +03:00
Дмитрий cf95b87f30 feat(import): Eloquent-модели ImportLog + ImportUnknownStatus
- ImportLog: $attributes зеркалят DB DEFAULT'ов (status/entity_type/dry_run),
  CREATED_AT/UPDATED_AT=null (таблица использует started_at/finished_at),
  casts для mapping_config (array) и dry_run (boolean)
- ImportUnknownStatus: scope unresolved() (whereNull mapped_to_slug),
  BelongsTo tenant
- Фабрики ImportLogFactory + ImportUnknownStatusFactory
- Тест ImportModelsTest (2/2, DatabaseTransactions, idempotent)
- ide-helper:models перегенерирован под новые модели
- phpstan-baseline регенерирован (квирк 25: TestCall::$tenant/$user)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:29:33 +03:00
Дмитрий 9ff5d1ce2e feat(billing): wallet/transactions/invoices read API (E3)
GET /api/billing/wallet (баланс + тариф + runway), /transactions
(пагинированный balance_transactions с фильтром type), /invoices
(saas_invoices, real-but-empty до Б-1). TariffPlan модель +
Tenant::tariff() relation + BalanceTransactionFactory.

Sprint 2 Plan C, audit E3 (backend).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 07:08:09 +03:00
Дмитрий 746a4f5f18 feat(api): outbound webhook settings endpoints (closes J5 part 2)
Audit J5/D4/D5: the outbound_webhook_subscriptions table existed in
schema but had zero code. Adds the OutboundWebhookSubscription model +
factory and WebhookSettingsController with GET/PUT
/api/tenants/me/webhook-settings (one subscription per tenant; secret
generated + returned once on creation, bcrypt-hashed) and POST
/api/webhooks/test (unsigned connectivity check — HMAC-signed event
delivery is a separate post-MVP epic). Tenant-scoped via auth:sanctum +
tenant middleware.

phpstan-baseline.neon: additive-only entries for new test file
(Pest\PendingCalls\TestCall false-positives — documented project pattern)
and OutboundWebhookSubscriptionFactory method.childReturnType (same
pattern as ProjectFactory/TenantFactory/UserFactory already in baseline).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:13:32 +03:00
Дмитрий 787b50bc36 feat(api): api_keys model + GET/regenerate endpoints (closes J5 part 1)
Audit J5/D3: the api_keys table existed in schema but had zero code.
Adds the ApiKey model + factory, and ApiKeyController with GET
/api/api-keys (list active keys, key_hash hidden) and POST
/api/api-keys/regenerate (deactivate prior + create new, full key
returned once, bcrypt-hashed in DB). Tenant-scoped via auth:sanctum +
tenant middleware (RLS on api_keys). phpstan-baseline.neon updated for
Pest PendingCalls false-positives in the new test file; also removes
8 pre-existing stale ignore.unmatched entries (properties now resolved
by existing @mixin IdeHelper* docblocks — confirmed pre-existing via
git stash test before Task 3 changes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:53:35 +03:00
Дмитрий 77691c8805 feat(projects): backend support for subject-level regions array (Plan 6 Task 3)
- Project model: +regions in fillable + cast via PostgresIntArray
  (custom Eloquent cast for PG INT[] — Laravel stock 'array' uses JSON
  which Postgres rejects on native INT[] columns)
- StoreProjectRequest / UpdateProjectRequest: drop region_mask/mode rules,
  add regions array validation (1..89 each, present/sometimes)
- ProjectService::create: dual-write — regions источник истины + legacy
  region_mask=255 + region_mode='include' для PhonePrefixService/LeadRouter
  compatibility (Plan 6.5 cleanup will remove dual-write)
- +5 Pest tests covering create/update/dual-write/validation rejection
- Drive-by: SchemaDeltaTest indexes pin 117 → 118 (Plan 6 v8.20 carryover
  from Task 1; should ideally have landed in Task 1 commit ce0b789)
- phpstan-baseline: +3 entries for Project::$regions until next ide-helper
  regen; existing Pest actingAs counts bumped 9→12 / 6→8 for new tests

Verified: Pest --parallel 747/744/3sk/0/0 (5 new tests pass +
SchemaDeltaTest now green), phpstan 0 errors, pint clean, gitleaks 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 05:39:43 +03:00
Дмитрий 9d2e7270de feat(projects): Plan 5 Task 3 — store + StoreProjectRequest + ProjectService::create
- StoreProjectRequest: 3-way conditional validation (site domain regex, call 7\d{10}, sms senders required)
- ProjectService::create(): max_projects limit check via Tenant.limits JSONB + dispatch SyncSupplierProjectJob
- ProjectController: constructor DI + store() method returning 201
- SyncSupplierProjectJob: stub (Task 4 полная реализация)
- POST /api/projects route inside auth:sanctum+tenant group (name projects.store)
- Migration add_limits_to_tenants: JSONB DEFAULT '{}' per-tenant limits column
- Tenant model: limits added to fillable + casts as array
- schema.sql/CHANGELOG: tenants.limits documented in v8.20
- phpstan-baseline: +8 actingAs entries for new test file
- Quirk: region_mode in request uses 'include'/'exclude' (schema CHECK) not 'all'/'whitelist' (plan spec typo)
- Quirk: Project::first() → Project::where('signal_identifier','x.ru')->latest()->first() (no RefreshDatabase, persistent test DB)
- 8/8 ProjectsStoreTest passed; 699/706 total (4 pre-existing failures unchanged)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 18:29:54 +03:00
Дмитрий e242e7d7fc fix(projects): Plan 5 Task 2 code-review fixes (2 Important + 2 Minor)
I-1/M-1: introduce resolvedSupplierProjects() private helper on Project
model; rewrite aggregateSyncStatus(), aggregateLastSyncedAt(),
getSupplierLinks() to read from eager-loaded supplierB1/B2/B3 relations
instead of SupplierProject::find() — eliminates up to 120 SELECTs/page.

I-2: aggregateLastSyncedAt() now uses sortBy(timestamp) instead of
Collection::min() on Carbon objects (string-comparison was unreliable).

M-2: add explanatory comment on intval+array_filter silent-drop behaviour
in the ?ids batch-fetch path.

M-3: new test — ?ids batch silently excludes foreign-tenant project IDs.
M-4: new test — show returns 200 for archived project (read preserved).

PHPStan baseline updated: 2 new test functions raise actingAs() count 7→9.
Tests: 9/9 passed (33 assertions). Larastan: 0 errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 18:15:36 +03:00
Дмитрий 35310b5517 feat(projects): Plan 5 Task 2 — index expanded (filters/search/pagination/ids) + show
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 18:08:01 +03:00
Дмитрий 622773f929 fix(db): Plan 5 Task 1 code-review fixes (2 Important + 2 Minor)
I-1: scopeActive docblock — явное предупреждение что scope НЕ фильтрует
     is_active; приостановленные проекты попадают; пример комбинирования.

I-2: migration down() — комментарий об асимметрии с up() и риске drift
     с schema.sql v8.20 при случайном rollback.

M-1: archived_at перемещён в $fillable на позицию сразу после is_active
     (lifecycle-state рядом с lifecycle-state, как указано в плане).

M-2: CHANGELOG header счётчик восемнадцать → девятнадцать записей.

Tests: ArchivedAtTest 2/2 PASS (4 assertions, 472 ms). No behavior change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 17:51:33 +03:00
Дмитрий 144d4cbb98 feat(db): Plan 5 Task 1 — schema delta v8.19 → v8.20 + Project.archived_at
Schema delta (1 правка в db/schema.sql):
- projects + archived_at TIMESTAMPTZ NULL — soft archive flow (отличие от
  is_active=false который = pause).

Метрики: 62 базовых таблицы / 117 индексов / 39 RLS (без изменений).

Сопутствующие правки:
- db/CHANGELOG_schema.md — v8.20 entry.
- app/Models/Project — fillable+casts: archived_at datetime + scopeActive +
  scopeArchived (whereNull/whereNotNull archived_at).
- Migration guard: Schema::hasColumn() проверка перед ALTER TABLE — предотвращает
  "duplicate column" после migrate:fresh (schema.sql v8.20 уже содержит колонку).

Tests:
- ArchivedAtTest.php — 2 it() блоков: archived_at колонка timestamptz + fillable/casts.
- pest --filter=ArchivedAtTest: 2/2 PASS (4 assertions, 485 ms).
- Full suite: 689/686+3 skipped/0 failed (2094 assertions, 84638 ms).

Quirk зафиксирован: Schema::getColumnType('projects', 'archived_at') → 'timestamptz'
(не 'timestamp') — PostgreSQL TIMESTAMPTZ → Doctrine/Laravel native type string.
План spec ожидал 'timestamp', скорректировано в тесте с комментарием.

Spec: docs/superpowers/specs/2026-05-10-claude-brain-extraction-design.md (Plan 5).
Plan: docs/superpowers/plans/2026-05-10-claude-brain-extraction.md Task 1.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 17:39:46 +03:00
Дмитрий e5ee9dce0d fix(db): Plan 4 Task 1 code-review fixes (4 Important issues)
- chk_lead_charges_prepaid_zero_price moved inline в CREATE TABLE lead_charges
  (consistent с ~30 другими CHECK constraint'ами в schema.sql).
- LeadCharge.casts() — убран no-op 'charge_source' => 'string' (Eloquent
  возвращает VARCHAR как string без cast'а; consistent с SupplierLead.platform).
- SchemaDeltaTest — добавлен uses(DatabaseTransactions::class) для tests 1+2
  (rollback после теста, project convention LeadChargeTest/PricingTierTest).
- SchemaDeltaTest test #5 — замена destructive migrate:fresh на static parse
  count(CREATE TABLE) / count(CREATE INDEX) / count(CREATE POLICY) в schema.sql.
  Устраняет cross-test coupling в sequential pest run; параллельно убирает
  LARAVEL_PARALLEL_TESTING skip — теперь все 5 тестов выполняются в parallel.

Метрики из static parse: 62 base tables / 117 indexes / 39 RLS policies
(совпадают с schema v8.19, spec §2.4).

All 5 SchemaDeltaTest assertions still pass. No new schema changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:51:18 +03:00
Дмитрий a907fea031 feat(db): Plan 4 Task 1 — schema delta v8.18 → v8.19 + models/seeder
Schema delta (4 правки в db/schema.sql):
- tenants + delivered_in_month INT NOT NULL DEFAULT 0 CHECK (>=0) — month
  counter для PricingTierResolver O(1) lookup на горячем пути.
- lead_charges + charge_source VARCHAR(8) DEFAULT 'rub' CHECK IN ('prepaid','rub')
  + ALTER ADD CONSTRAINT chk_lead_charges_prepaid_zero_price (prepaid → price=0).
- supplier_leads + recovered_from_csv_at TIMESTAMPTZ + partial index
  WHERE recovered_from_csv_at IS NOT NULL.
- Новая таблица supplier_csv_reconcile_log (SaaS-level, без RLS) + 2 индекса
  (started_at DESC, partial status WHERE status IN ('drift_alert','failed')).

Метрики: 61 → 62 базовых таблиц / 114 → 117 индексов / 39 RLS (без изменений).

Сопутствующие правки:
- db/CHANGELOG_schema.md — v8.19 entry (18 записей).
- db/02_grants.sql — GRANT SELECT,INSERT,UPDATE on supplier_csv_reconcile_log
  + GRANT USAGE,SELECT on sequence для crm_supplier_worker.
- app/Models/Tenant — fillable+casts: delivered_in_month integer.
- app/Models/LeadCharge — fillable+casts: charge_source string.
- app/Models/SupplierLead — fillable+casts: recovered_from_csv_at datetime.
- LeadChargeFactory — defaults charge_source='rub' + prepaid() state.
- PricingTierSeeder (новый) — 7 ступеней дефолтного тарифа (placeholder,
  Plan 4 Открытый вопрос #1: 100/200/400/800/1500/3000/∞ leads at
  50000/45000/40000/35000/30000/27000/25000 копеек).
- DatabaseSeeder — call PricingTierSeeder; убран broken Laravel scaffold
  User::factory(['name' => ...]) (наша схема first_name/last_name).

Tests (Plan 4 surface):
- SchemaDeltaTest.php — 5 it() блоков: delivered_in_month CHECK, charge_source
  CHECK на prepaid+zero-price, recovered_from_csv_at колонка, reconcile_log
  таблица+status CHECK, migrate:fresh idempotency (skip in parallel).
- pest --filter=SchemaDeltaTest: 5/5 PASS (9 assertions, 2076 ms).
- pest --filter='Tenant|LeadCharge|SupplierLead|Plan4': 111/111 PASS.

CI gates:
- composer pint: passed.
- composer stan: passed (0 errors above baseline — @phpstan-ignore-next-line
  на $this->markTestSkipped в Pest closure rebound context).

Verify:
- migrate:fresh --seed на DB_DATABASE=liderra_testing: 0 errors, 754 ms.
- PricingTier::count() = 7.

Концерны (НЕ блокируют Task 1):
- pest --parallel: 617/622 PASS + 4 skipped + 1 flaky FAIL — flaky test
  колеблется между ProjectExtensionsTest::supplierB1_B2_B3_relations
  (SupplierProjectFactory race: closure пикает signal_type=sms до override
  platform=B1 → CHECK chk_supplier_projects_b1_not_for_sms violation)
  и NewLeadNotificationTest::webhook_дубль_Биз_19 (известный microsecond
  precision quirk в anti-spam exclusion, memory feedback_environment.md).
  Оба теста существуют на main (HEAD 0802f7c = plan4-billing branch HEAD
  до этого commit'а), Plan 4 их не трогает. Фиксы — вне Task 1 scope.

Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §2.
Plan: docs/superpowers/plans/2026-05-11-plan4-billing-csv-admin-plan.md Task 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:38:38 +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
Дмитрий 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
Дмитрий 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
Дмитрий 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