From 7ab7cf51cb68a6ec9ca40171fdde4a6768fe6d88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 10 May 2026 12:10:04 +0300 Subject: [PATCH] =?UTF-8?q?docs(plans):=20supplier=20integration=20plan=20?= =?UTF-8?q?1/5=20=E2=80=94=20Foundation=20(schema=20+=20models=20+=20valid?= =?UTF-8?q?ators)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 12 tasks + 3 verification gates covering: - 5 migrations: extend projects, supplier_projects, pricing_tiers, lead_charges, supplier_sync_log - 4 new Eloquent models + factories + Pest unit tests - Project model extension with signal_type/sms_*/supplier_b1/b2/b3 relations - 3 signal validators (Domain, Phone, SmsSender) with edge-case datasets - SupplierProjectResolver service with B1+SMS guard - Comprehensive verification gate: Larastan + squawk + pgFormatter + cspell + markdownlint + cycle-check + code-review subagent Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §2, §7 cspell-words: +vashinvestor .gitleaks.toml allowlist: +test phones for validator datasets Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitleaks.toml | 6 + cspell-words.txt | 1 + .../2026-05-10-supplier-foundation-plan.md | 2515 +++++++++++++++++ 3 files changed, 2522 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-10-supplier-foundation-plan.md diff --git a/.gitleaks.toml b/.gitleaks.toml index dcef634d..3bfaf7c8 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -131,6 +131,12 @@ regexes = [ '''(?:\+?7|8)\s?\(?XXX\)?\s?XXX[\s\-]?XX[\s\-]?XX''', '''79000000000''', '''79991234567''', + '''74955551212''', + '''89991234567''', + '''799912345678''', + '''7999123456''', + '''\+79991234567''', + '''7 999 123 45 67''', # 12-значные номера-маски для скриншотов и тестов '''[78]\(?[*X]{3}\)?\s?[*X]{3}[\s\-]?[*X]{2}[\s\-]?[*X0-9]{2}''' ] diff --git a/cspell-words.txt b/cspell-words.txt index 4de42f17..4a46c50a 100644 --- a/cspell-words.txt +++ b/cspell-words.txt @@ -896,3 +896,4 @@ symfony логинится encrypter PHPSESSID +vashinvestor diff --git a/docs/superpowers/plans/2026-05-10-supplier-foundation-plan.md b/docs/superpowers/plans/2026-05-10-supplier-foundation-plan.md new file mode 100644 index 00000000..411b0afe --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-supplier-foundation-plan.md @@ -0,0 +1,2515 @@ +# Supplier Integration — Foundation (Plan 1/5) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> +> **Verification is mandatory at every gate.** This plan was authored under the directive: "перепроверять на все возможные ошибки, в логике, в коде, в цикличностях, опечатки и тд". Do not skip verification gates. + +**Goal:** Расширить existing schema и Eloquent-модели Лидерры под supplier-integration data model: 3 новых таблицы (`supplier_projects`, `pricing_tiers`, `lead_charges`, `supplier_sync_log`), расширение `projects` под signal_type/identifier/SMS-поля, обновление `db/schema.sql`, RLS на tenant-scoped таблицах, factories + Pest-тесты. + +**Architecture:** Используем существующие паттерны проекта: SetTenantContext middleware + RLS, Eloquent-модели c HasFactory (+ SoftDeletes где нужно), Pest c DatabaseTransactions + явный `SET app.current_tenant_id`. Новые таблицы — два класса: tenant-scoped (с RLS) и SaaS-level (без RLS, REVOKE на crm_app_user). Миграции в `database/migrations/` отдельными файлами + sync с `db/schema.sql` (single source of truth) + запись в `db/CHANGELOG_schema.md`. + +**Tech Stack:** Laravel 13.7, PostgreSQL 16, Pest 4, Larastan, squawk, pgFormatter (existing toolchain). + +**Spec:** [docs/superpowers/specs/2026-05-10-supplier-integration-design.md](../specs/2026-05-10-supplier-integration-design.md) — раздел §2 (Модель данных), §7 (Биллинг), §9 (Схема БД). + +**Verification philosophy:** TDD для каждой задачи (failing test first → green → commit). Verification gates после каждой группы задач. Финальный «Comprehensive Verification» в конце плана включает: Larastan, Pest, squawk, pgFormatter, cspell, markdownlint, миграция fresh, RLS-смок, dependency-cycle check, code-reviewer subagent. + +--- + +## File Structure + +### Files to create + +``` +database/migrations/ + 2026_05_10_000001_extend_projects_for_supplier_integration.php + 2026_05_10_000002_create_supplier_projects_table.php + 2026_05_10_000003_create_pricing_tiers_table.php + 2026_05_10_000004_create_lead_charges_table.php + 2026_05_10_000005_create_supplier_sync_log_table.php + +app/Models/ + SupplierProject.php + PricingTier.php + LeadCharge.php + SupplierSyncLog.php + +database/factories/ + SupplierProjectFactory.php + PricingTierFactory.php + LeadChargeFactory.php + SupplierSyncLogFactory.php + +tests/Unit/Models/ + SupplierProjectTest.php + PricingTierTest.php + LeadChargeTest.php + SupplierSyncLogTest.php + +tests/Feature/Integration/ + ProjectsSchemaExtensionTest.php + LeadChargesRlsTest.php + SupplierProjectsAccessTest.php +``` + +### Files to modify + +``` +app/Models/Project.php # add casts, relationships, signal_type accessor +database/factories/ProjectFactory.php # extend with new fields +db/schema.sql # mirror migration changes (source of truth) +db/CHANGELOG_schema.md # entry v8.12 → v8.13 +``` + +### Responsibilities (one purpose per file) + +- **Migration files** — DDL для одной концепции (extend projects / create supplier_projects / etc.). Не смешивать. +- **Models** — только Eloquent-модель: relationships, casts, scopes. Никакой бизнес-логики. +- **Factories** — генерация фейковых данных для тестов. Минимум валидных значений, без edge cases. +- **Unit tests** — модель в изоляции: casts работают, relationships определены, scope-методы корректны. +- **Integration tests** — миграция применяется чисто, RLS изолирует tenant'ы, FK работают, индексы созданы. + +--- + +## Pre-flight checks + +- [ ] **Step P1:** Verify clean working tree + +Run: `git status --short` +Expected: только untracked файлы (известные плановые), нет staged/modified в `app/`, `database/`, `db/`. + +Если есть посторонние модификации — остановись и согласуй с пользователем. + +- [ ] **Step P2:** Verify baseline tests pass + +Run (в `c:\моя\проекты\портал crm\Документация\app\`): `composer test` +Expected: `Tests: 403 passed` (или текущая baseline-цифра). + +Если падает что-то постороннее — остановись, не начинай новую работу на сломанном baseline. + +- [ ] **Step P3:** Verify Larastan baseline clean + +Run: `composer stan` +Expected: `[OK] No errors` + +Если есть ошибки — остановись. + +- [ ] **Step P4:** Snapshot текущей версии schema + +Run: `head -5 db/schema.sql` +Запиши версию (например, "v8.11") — пригодится при обновлении CHANGELOG. + +--- + +## Task 1: Migration `extend_projects_for_supplier_integration` + +**Why:** Существующий `projects` table содержит часть нужных полей (`tag`, `daily_limit_target`, `region_mask`, `delivery_days_mask`, `is_active`). Не хватает: `signal_type`, `signal_identifier`, `sms_senders`, `sms_keyword`, `delivered_in_month`, FK на supplier_projects. + +**Files:** + +- Create: `database/migrations/2026_05_10_000001_extend_projects_for_supplier_integration.php` +- Create: `tests/Feature/Integration/ProjectsSchemaExtensionTest.php` + +- [ ] **Step 1.1: Write failing migration test** + +Create `tests/Feature/Integration/ProjectsSchemaExtensionTest.php`: + +```php +toBeTrue(); + + $check = DB::selectOne( + "SELECT pg_get_constraintdef(c.oid) AS def + FROM pg_constraint c + JOIN pg_class t ON c.conrelid = t.oid + WHERE t.relname = 'projects' AND c.conname LIKE '%signal_type%'" + ); + + expect($check->def)->toContain("'site'", "'call'", "'sms'"); +}); + +test('projects table has signal_identifier text column', function () { + expect(Schema::hasColumn('projects', 'signal_identifier'))->toBeTrue(); +}); + +test('projects table has sms_senders jsonb array column', function () { + expect(Schema::hasColumn('projects', 'sms_senders'))->toBeTrue(); + + $type = DB::selectOne( + "SELECT data_type FROM information_schema.columns + WHERE table_name = 'projects' AND column_name = 'sms_senders'" + ); + + expect($type->data_type)->toBe('jsonb'); +}); + +test('projects table has sms_keyword nullable text column', function () { + expect(Schema::hasColumn('projects', 'sms_keyword'))->toBeTrue(); + + $col = DB::selectOne( + "SELECT is_nullable FROM information_schema.columns + WHERE table_name = 'projects' AND column_name = 'sms_keyword'" + ); + + expect($col->is_nullable)->toBe('YES'); +}); + +test('projects table has delivered_in_month integer counter', function () { + expect(Schema::hasColumn('projects', 'delivered_in_month'))->toBeTrue(); +}); + +test('projects table has supplier_b1_project_id, b2, b3 nullable FK columns', function () { + foreach (['supplier_b1_project_id', 'supplier_b2_project_id', 'supplier_b3_project_id'] as $col) { + expect(Schema::hasColumn('projects', $col))->toBeTrue(); + } +}); + +test('signal_type sms requires sms_senders non-empty (CHECK constraint)', function () { + $check = DB::selectOne( + "SELECT pg_get_constraintdef(c.oid) AS def + FROM pg_constraint c + JOIN pg_class t ON c.conrelid = t.oid + WHERE t.relname = 'projects' AND c.conname = 'projects_sms_senders_required_for_sms'" + ); + + expect($check)->not->toBeNull(); + expect($check->def)->toContain('signal_type'); +}); +``` + +- [ ] **Step 1.2: Run test — verify it fails** + +Run: `cd app && ./vendor/bin/pest tests/Feature/Integration/ProjectsSchemaExtensionTest.php -v` +Expected: 7 failures (columns missing). + +- [ ] **Step 1.3: Write the migration** + +Create `database/migrations/2026_05_10_000001_extend_projects_for_supplier_integration.php`: + +```php +string('signal_type', 16)->nullable()->after('type'); + $table->text('signal_identifier')->nullable()->after('signal_type'); + $table->jsonb('sms_senders')->nullable()->after('signal_identifier'); + $table->text('sms_keyword')->nullable()->after('sms_senders'); + $table->unsignedInteger('delivered_in_month')->default(0)->after('effective_daily_limit_today'); + $table->unsignedBigInteger('supplier_b1_project_id')->nullable()->after('delivered_in_month'); + $table->unsignedBigInteger('supplier_b2_project_id')->nullable()->after('supplier_b1_project_id'); + $table->unsignedBigInteger('supplier_b3_project_id')->nullable()->after('supplier_b2_project_id'); + }); + + DB::statement(" + ALTER TABLE projects + ADD CONSTRAINT projects_signal_type_check + CHECK (signal_type IS NULL OR signal_type IN ('site','call','sms')) + "); + + DB::statement(" + ALTER TABLE projects + ADD CONSTRAINT projects_sms_senders_required_for_sms + CHECK ( + signal_type <> 'sms' + OR (sms_senders IS NOT NULL AND jsonb_typeof(sms_senders) = 'array' AND jsonb_array_length(sms_senders) > 0) + ) + "); + + DB::statement(" + ALTER TABLE projects + ADD CONSTRAINT projects_signal_identifier_required_for_site_call + CHECK ( + signal_type NOT IN ('site','call') + OR (signal_identifier IS NOT NULL AND length(trim(signal_identifier)) > 0) + ) + "); + + Schema::table('projects', function (Blueprint $table) { + $table->index(['tenant_id', 'signal_type', 'signal_identifier'], 'idx_projects_tenant_signal'); + }); + } + + public function down(): void + { + Schema::table('projects', function (Blueprint $table) { + $table->dropIndex('idx_projects_tenant_signal'); + }); + + DB::statement('ALTER TABLE projects DROP CONSTRAINT IF EXISTS projects_signal_identifier_required_for_site_call'); + DB::statement('ALTER TABLE projects DROP CONSTRAINT IF EXISTS projects_sms_senders_required_for_sms'); + DB::statement('ALTER TABLE projects DROP CONSTRAINT IF EXISTS projects_signal_type_check'); + + Schema::table('projects', function (Blueprint $table) { + $table->dropColumn([ + 'supplier_b3_project_id', + 'supplier_b2_project_id', + 'supplier_b1_project_id', + 'delivered_in_month', + 'sms_keyword', + 'sms_senders', + 'signal_identifier', + 'signal_type', + ]); + }); + } +}; +``` + +- [ ] **Step 1.4: Lint migration with squawk** + +Run: `cd app && npx --yes squawk database/migrations/2026_05_10_000001_extend_projects_for_supplier_integration.php` (или конкретная команда из существующего lefthook.yml — посмотри `lefthook.yml` jobs). + +Expected: `0 issues`. + +Если squawk ругается на ALTER TABLE без `IF NOT EXISTS` или на missing index — поправь. + +- [ ] **Step 1.5: Apply migration** + +Run: `cd app && php artisan migrate` +Expected: `Migrating: 2026_05_10_000001_extend_projects_for_supplier_integration ... DONE` + +- [ ] **Step 1.6: Run tests — verify pass** + +Run: `cd app && ./vendor/bin/pest tests/Feature/Integration/ProjectsSchemaExtensionTest.php -v` +Expected: 7 passed. + +- [ ] **Step 1.7: Verify rollback works** + +Run: `cd app && php artisan migrate:rollback --step=1` +Expected: rollback DONE; columns/constraints/index dropped. + +Run: `cd app && php artisan migrate` +Expected: re-applied DONE. + +- [ ] **Step 1.8: Sync `db/schema.sql`** + +Open `db/schema.sql`, найди блок `CREATE TABLE projects (...)`, добавь новые колонки и CHECK-constraints в том же порядке что в миграции. Добавь индекс. Не забудь обновить версию в шапке (например, v8.11 → v8.12). + +Run: `cd app && composer format:sql:check` (или альтернативная команда из существующих скриптов) — pgFormatter не должен ругаться. + +- [ ] **Step 1.9: Update `db/CHANGELOG_schema.md`** + +Добавь запись в начало: + +```markdown +## v8.12 — 2026-05-10 + +- `projects` extended for supplier integration: +signal_type (enum site/call/sms), +signal_identifier (text), +sms_senders (jsonb array), +sms_keyword (nullable text), +delivered_in_month (uint), +supplier_b{1,2,3}_project_id (nullable FK placeholder). 3 CHECK constraints + 1 composite index. +- Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §2.1 +``` + +- [ ] **Step 1.10: Commit** + +Run: + +```bash +cd "c:/моя/проекты/портал crm/Документация" +git add app/database/migrations/2026_05_10_000001_extend_projects_for_supplier_integration.php \ + app/tests/Feature/Integration/ProjectsSchemaExtensionTest.php \ + db/schema.sql \ + db/CHANGELOG_schema.md +git commit -m "feat(db): extend projects for supplier integration (signal_type, identifier, sms_senders, sms_keyword, delivered_in_month, b1/b2/b3 FK placeholders)" +``` + +--- + +## Task 2: Migration `create_supplier_projects_table` + +**Why:** SaaS-level агрегатная сущность — отражает что именно у поставщика создано/синхронизировано. Не tenant-scoped (несколько клиентов Лидерры разделяют один supplier_project — sharing model). + +**Files:** + +- Create: `database/migrations/2026_05_10_000002_create_supplier_projects_table.php` +- Create: `tests/Feature/Integration/SupplierProjectsAccessTest.php` + +- [ ] **Step 2.1: Write failing structure test** + +Create `tests/Feature/Integration/SupplierProjectsAccessTest.php`: + +```php +toBeTrue(); + + foreach ([ + 'id', 'platform', 'signal_type', 'unique_key', 'supplier_external_id', + 'current_limit', 'current_workdays', 'current_regions', + 'sync_status', 'last_synced_at', 'inactive_since', + 'created_at', 'updated_at', + ] as $col) { + expect(Schema::hasColumn('supplier_projects', $col)) + ->toBeTrue("column {$col} missing"); + } +}); + +test('supplier_projects has unique constraint on (platform, unique_key)', function () { + $idx = DB::selectOne( + "SELECT indexdef FROM pg_indexes + WHERE tablename = 'supplier_projects' AND indexname = 'supplier_projects_platform_unique_key_unique'" + ); + expect($idx)->not->toBeNull(); + expect($idx->indexdef)->toContain('UNIQUE'); +}); + +test('supplier_projects platform check constraint allows only B1, B2, B3', function () { + $check = DB::selectOne( + "SELECT pg_get_constraintdef(c.oid) AS def + FROM pg_constraint c JOIN pg_class t ON c.conrelid = t.oid + WHERE t.relname = 'supplier_projects' AND c.conname = 'supplier_projects_platform_check'" + ); + expect($check->def)->toContain("'B1'", "'B2'", "'B3'"); +}); + +test('supplier_projects sync_status check constraint', function () { + $check = DB::selectOne( + "SELECT pg_get_constraintdef(c.oid) AS def + FROM pg_constraint c JOIN pg_class t ON c.conrelid = t.oid + WHERE t.relname = 'supplier_projects' AND c.conname = 'supplier_projects_sync_status_check'" + ); + expect($check->def)->toContain("'pending'", "'ok'", "'failed'"); +}); + +test('supplier_projects has NO RLS policy (SaaS-level)', function () { + $rls = DB::selectOne( + "SELECT relrowsecurity FROM pg_class WHERE relname = 'supplier_projects'" + ); + expect((bool) $rls->relrowsecurity)->toBeFalse(); +}); + +test('supplier_projects has REVOKE ALL on crm_app_user', function () { + $grants = DB::select( + "SELECT privilege_type FROM information_schema.role_table_grants + WHERE table_name = 'supplier_projects' AND grantee = 'crm_app_user'" + ); + + expect($grants)->toBeArray(); + expect($grants)->toBeEmpty('crm_app_user must have no direct privileges on supplier_projects'); +}); +``` + +- [ ] **Step 2.2: Run test — verify it fails** + +Run: `cd app && ./vendor/bin/pest tests/Feature/Integration/SupplierProjectsAccessTest.php -v` +Expected: 6 failures. + +- [ ] **Step 2.3: Write migration** + +Create `database/migrations/2026_05_10_000002_create_supplier_projects_table.php`: + +```php +id(); + $table->string('platform', 4); + $table->string('signal_type', 16); + $table->text('unique_key'); + $table->string('supplier_external_id', 64)->nullable(); + $table->unsignedInteger('current_limit')->default(0); + $table->jsonb('current_workdays')->nullable(); + $table->jsonb('current_regions')->nullable(); + $table->string('sync_status', 16)->default('pending'); + $table->timestamp('last_synced_at')->nullable(); + $table->timestamp('inactive_since')->nullable(); + $table->timestamps(); + + $table->unique(['platform', 'unique_key'], 'supplier_projects_platform_unique_key_unique'); + $table->index('sync_status'); + $table->index('inactive_since'); + }); + + DB::statement(" + ALTER TABLE supplier_projects + ADD CONSTRAINT supplier_projects_platform_check + CHECK (platform IN ('B1','B2','B3')) + "); + + DB::statement(" + ALTER TABLE supplier_projects + ADD CONSTRAINT supplier_projects_signal_type_check + CHECK (signal_type IN ('site','call','sms')) + "); + + DB::statement(" + ALTER TABLE supplier_projects + ADD CONSTRAINT supplier_projects_sync_status_check + CHECK (sync_status IN ('pending','ok','failed')) + "); + + DB::statement(" + ALTER TABLE supplier_projects + ADD CONSTRAINT supplier_projects_b1_not_for_sms + CHECK (NOT (platform = 'B1' AND signal_type = 'sms')) + "); + + DB::statement('REVOKE ALL ON TABLE supplier_projects FROM crm_app_user'); + } + + public function down(): void + { + Schema::dropIfExists('supplier_projects'); + } +}; +``` + +- [ ] **Step 2.4: Lint with squawk** + +Run: `cd app && npx --yes squawk database/migrations/2026_05_10_000002_create_supplier_projects_table.php` +Expected: 0 issues. (Возможно warning на missing CONCURRENTLY index — для пустой таблицы при create это false-positive, можно add to .squawkrc.) + +- [ ] **Step 2.5: Apply and run tests** + +Run: + +```bash +cd app +php artisan migrate +./vendor/bin/pest tests/Feature/Integration/SupplierProjectsAccessTest.php -v +``` + +Expected: migration done, 6 tests passed. + +- [ ] **Step 2.6: Verify rollback** + +Run: `cd app && php artisan migrate:rollback --step=1 && php artisan migrate` +Expected: оба шага DONE. + +- [ ] **Step 2.7: Sync `db/schema.sql` + CHANGELOG** + +Add `CREATE TABLE supplier_projects (...)` block в schema.sql после блока `projects`. Bump version in header to v8.13. Update CHANGELOG. + +- [ ] **Step 2.8: Commit** + +```bash +git add app/database/migrations/2026_05_10_000002_create_supplier_projects_table.php \ + app/tests/Feature/Integration/SupplierProjectsAccessTest.php \ + db/schema.sql db/CHANGELOG_schema.md +git commit -m "feat(db): create supplier_projects table (SaaS-level aggregate, B1/B2/B3 platforms)" +``` + +--- + +## Task 3: Migration `create_pricing_tiers_table` + +**Why:** §7 спеки — 7-ступенчатый объёмный тариф, конфигурируемый админом Лидерры. SaaS-level (один на всю Лидерру; per-tenant override — out of scope для MVP). + +**Files:** + +- Create: `database/migrations/2026_05_10_000003_create_pricing_tiers_table.php` + +- [ ] **Step 3.1: Write failing test (in `LeadChargesRlsTest.php` namespace, секция pricing_tiers)** + +Дополни новый файл `tests/Feature/Integration/PricingTiersTest.php`: + +```php +toBeTrue(); + + foreach (['id', 'tier_no', 'leads_in_tier', 'price_per_lead_kopecks', 'is_active', 'effective_from', 'created_at', 'updated_at'] as $col) { + expect(Schema::hasColumn('pricing_tiers', $col))->toBeTrue("column {$col} missing"); + } +}); + +test('pricing_tiers tier_no constrained to 1..7', function () { + $check = DB::selectOne( + "SELECT pg_get_constraintdef(c.oid) AS def + FROM pg_constraint c JOIN pg_class t ON c.conrelid = t.oid + WHERE t.relname = 'pricing_tiers' AND c.conname = 'pricing_tiers_tier_no_check'" + ); + expect($check->def)->toContain('1', '7'); +}); + +test('pricing_tiers has unique on (tier_no, effective_from) for active rows', function () { + $idx = DB::selectOne( + "SELECT indexdef FROM pg_indexes + WHERE tablename = 'pricing_tiers' AND indexname = 'pricing_tiers_tier_effective_unique'" + ); + expect($idx)->not->toBeNull(); +}); + +test('pricing_tiers price stored in kopecks (integer, not float)', function () { + $col = DB::selectOne( + "SELECT data_type FROM information_schema.columns + WHERE table_name = 'pricing_tiers' AND column_name = 'price_per_lead_kopecks'" + ); + expect($col->data_type)->toBe('integer'); +}); +``` + +- [ ] **Step 3.2: Run test — verify fail** + +Run: `cd app && ./vendor/bin/pest tests/Feature/Integration/PricingTiersTest.php -v` +Expected: 4 failures. + +- [ ] **Step 3.3: Write migration** + +Create `database/migrations/2026_05_10_000003_create_pricing_tiers_table.php`: + +```php +id(); + $table->unsignedSmallInteger('tier_no'); + $table->unsignedInteger('leads_in_tier')->nullable(); + $table->unsignedInteger('price_per_lead_kopecks'); + $table->boolean('is_active')->default(true); + $table->date('effective_from'); + $table->timestamps(); + + $table->unique(['tier_no', 'effective_from'], 'pricing_tiers_tier_effective_unique'); + $table->index(['is_active', 'effective_from']); + }); + + DB::statement(" + ALTER TABLE pricing_tiers + ADD CONSTRAINT pricing_tiers_tier_no_check + CHECK (tier_no BETWEEN 1 AND 7) + "); + + DB::statement('REVOKE ALL ON TABLE pricing_tiers FROM crm_app_user'); + DB::statement('GRANT SELECT ON TABLE pricing_tiers TO crm_app_user'); + } + + public function down(): void + { + Schema::dropIfExists('pricing_tiers'); + } +}; +``` + +> **Why kopecks (не decimal):** избегаем floating-point округлений в money-расчётах. Формат: integer kopecks (1 руб = 100 копеек). UI converts at the edge. + +- [ ] **Step 3.4: Lint, apply, test** + +Run: + +```bash +cd app +npx --yes squawk database/migrations/2026_05_10_000003_create_pricing_tiers_table.php +php artisan migrate +./vendor/bin/pest tests/Feature/Integration/PricingTiersTest.php -v +``` + +Expected: 0 squawk issues, migration DONE, 4 tests passed. + +- [ ] **Step 3.5: Sync schema.sql + CHANGELOG, commit** + +```bash +git add app/database/migrations/2026_05_10_000003_create_pricing_tiers_table.php \ + app/tests/Feature/Integration/PricingTiersTest.php \ + db/schema.sql db/CHANGELOG_schema.md +git commit -m "feat(db): create pricing_tiers table (7-step volume billing, kopecks integer)" +``` + +--- + +## Task 4: Migration `create_lead_charges_table` + +**Why:** §7.4 — ledger списаний за каждый доставленный лид. Tenant-scoped (RLS), append-only (insert) для аудита. + +**Files:** + +- Create: `database/migrations/2026_05_10_000004_create_lead_charges_table.php` +- Create: `tests/Feature/Integration/LeadChargesRlsTest.php` + +- [ ] **Step 4.1: Write failing test** + +Create `tests/Feature/Integration/LeadChargesRlsTest.php`: + +```php +toBeTrue(); + + foreach ([ + 'id', 'tenant_id', 'deal_id', 'deal_received_at', + 'tier_no', 'price_per_lead_kopecks', 'charged_at', 'created_at', + ] as $col) { + expect(Schema::hasColumn('lead_charges', $col))->toBeTrue("column {$col} missing"); + } +}); + +test('lead_charges has FK to deals (composite id, received_at)', function () { + $fk = DB::selectOne( + "SELECT pg_get_constraintdef(c.oid) AS def + FROM pg_constraint c JOIN pg_class t ON c.conrelid = t.oid + WHERE t.relname = 'lead_charges' AND c.contype = 'f' + LIMIT 1" + ); + + expect($fk)->not->toBeNull(); + expect($fk->def)->toContain('deals', 'received_at', 'id'); +}); + +test('lead_charges enforces RLS on tenant_id', function () { + $a = Tenant::factory()->create(); + $b = Tenant::factory()->create(); + + DB::transaction(function () use ($a) { + DB::statement('SET LOCAL app.current_tenant_id = ?', [$a->id]); + + DB::table('lead_charges')->insert([ + 'tenant_id' => $a->id, + 'deal_id' => 1, + 'deal_received_at' => now(), + 'tier_no' => 1, + 'price_per_lead_kopecks' => 6000, + 'charged_at' => now(), + 'created_at' => now(), + ]); + }); + + $countA = DB::transaction(function () use ($a) { + DB::statement('SET LOCAL app.current_tenant_id = ?', [$a->id]); + return DB::table('lead_charges')->count(); + }); + + $countB = DB::transaction(function () use ($b) { + DB::statement('SET LOCAL app.current_tenant_id = ?', [$b->id]); + return DB::table('lead_charges')->count(); + }); + + expect($countA)->toBe(1); + expect($countB)->toBe(0, 'tenant B must not see tenant A charges (RLS leak)'); +}); +``` + +> **Note:** этот RLS-тест предполагает что в дальнейших задачах будет `LeadChargeFactory`. Пока INSERT через DB::table, чтобы не блокировать тест на отсутствующей фабрике. + +- [ ] **Step 4.2: Run test — verify fail** + +Run: `cd app && ./vendor/bin/pest tests/Feature/Integration/LeadChargesRlsTest.php -v` +Expected: 3 failures. + +- [ ] **Step 4.3: Write migration** + +Create `database/migrations/2026_05_10_000004_create_lead_charges_table.php`: + +```php +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('deal_id'); + $table->timestamp('deal_received_at'); + $table->unsignedSmallInteger('tier_no'); + $table->unsignedInteger('price_per_lead_kopecks'); + $table->timestamp('charged_at'); + $table->timestamp('created_at')->useCurrent(); + + $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete(); + + $table->index(['tenant_id', 'charged_at']); + $table->index(['deal_id', 'deal_received_at']); + }); + + DB::statement(' + ALTER TABLE lead_charges + ADD CONSTRAINT lead_charges_deals_fk + FOREIGN KEY (deal_id, deal_received_at) + REFERENCES deals (id, received_at) + DEFERRABLE INITIALLY DEFERRED + ON DELETE CASCADE + '); + + DB::statement('ALTER TABLE lead_charges ENABLE ROW LEVEL SECURITY'); + DB::statement('ALTER TABLE lead_charges FORCE ROW LEVEL SECURITY'); + DB::statement(" + CREATE POLICY tenant_isolation ON lead_charges + USING (tenant_id = current_setting('app.current_tenant_id')::bigint) + WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::bigint) + "); + + DB::statement('GRANT SELECT, INSERT ON TABLE lead_charges TO crm_app_user'); + DB::statement('GRANT USAGE, SELECT ON SEQUENCE lead_charges_id_seq TO crm_app_user'); + } + + public function down(): void + { + Schema::dropIfExists('lead_charges'); + } +}; +``` + +- [ ] **Step 4.4: Lint, apply, test** + +Run: + +```bash +cd app +npx --yes squawk database/migrations/2026_05_10_000004_create_lead_charges_table.php +php artisan migrate +./vendor/bin/pest tests/Feature/Integration/LeadChargesRlsTest.php -v +``` + +Expected: squawk 0 issues, migration DONE, 3 tests passed. + +- [ ] **Step 4.5: Sync schema + CHANGELOG, commit** + +```bash +git add app/database/migrations/2026_05_10_000004_create_lead_charges_table.php \ + app/tests/Feature/Integration/LeadChargesRlsTest.php \ + db/schema.sql db/CHANGELOG_schema.md +git commit -m "feat(db): create lead_charges ledger (tenant-scoped RLS, FK to partitioned deals)" +``` + +--- + +## Task 5: Migration `create_supplier_sync_log_table` + +**Why:** §4.3 — лог синхронизаций для аудита/отладки. SaaS-level, append-only. + +**Files:** + +- Create: `database/migrations/2026_05_10_000005_create_supplier_sync_log_table.php` +- Create: `tests/Feature/Integration/SupplierSyncLogTest.php` + +- [ ] **Step 5.1: Write failing test** + +```php +toBeTrue(); + + foreach ([ + 'id', 'supplier_project_id', 'action', + 'request_payload', 'response_body', 'http_status', + 'error_message', 'duration_ms', 'created_at', + ] as $col) { + expect(Schema::hasColumn('supplier_sync_log', $col))->toBeTrue("column {$col} missing"); + } +}); +``` + +- [ ] **Step 5.2: Run failing test** + +`cd app && ./vendor/bin/pest tests/Feature/Integration/SupplierSyncLogTest.php -v` +Expected: 1 failure. + +- [ ] **Step 5.3: Write migration** + +```php +id(); + $table->unsignedBigInteger('supplier_project_id')->nullable(); + $table->string('action', 32); + $table->jsonb('request_payload')->nullable(); + $table->jsonb('response_body')->nullable(); + $table->unsignedSmallInteger('http_status')->nullable(); + $table->text('error_message')->nullable(); + $table->unsignedInteger('duration_ms')->nullable(); + $table->timestamp('created_at')->useCurrent(); + + $table->foreign('supplier_project_id') + ->references('id')->on('supplier_projects') + ->nullOnDelete(); + + $table->index('supplier_project_id'); + $table->index('action'); + $table->index('created_at'); + }); + + DB::statement(" + ALTER TABLE supplier_sync_log + ADD CONSTRAINT supplier_sync_log_action_check + CHECK (action IN ('create','update','delete','disable','session_refresh')) + "); + + DB::statement('REVOKE ALL ON TABLE supplier_sync_log FROM crm_app_user'); + } + + public function down(): void + { + Schema::dropIfExists('supplier_sync_log'); + } +}; +``` + +- [ ] **Step 5.4: Apply, test, commit** + +```bash +cd app +npx --yes squawk database/migrations/2026_05_10_000005_create_supplier_sync_log_table.php +php artisan migrate +./vendor/bin/pest tests/Feature/Integration/SupplierSyncLogTest.php -v +cd .. +git add app/database/migrations/2026_05_10_000005_create_supplier_sync_log_table.php \ + app/tests/Feature/Integration/SupplierSyncLogTest.php \ + db/schema.sql db/CHANGELOG_schema.md +git commit -m "feat(db): create supplier_sync_log audit table (SaaS-level, append-only)" +``` + +--- + +## ✓ Verification Gate A — DDL & migrations + +После Tasks 1–5 — обязательная проверка перед моделями. + +- [ ] **Step VA.1: Fresh migration smoke test** + +Run: + +```bash +cd app +php artisan migrate:fresh --seed +``` + +Expected: все миграции применяются с нуля без ошибок. Если что-то падает на fresh — есть ordering/dependency issue. + +- [ ] **Step VA.2: Run all integration schema tests** + +Run: `cd app && ./vendor/bin/pest tests/Feature/Integration/ -v` +Expected: все тесты passed. + +- [ ] **Step VA.3: Run full Pest baseline** + +Run: `cd app && composer test` +Expected: 403 + (новые добавленные) passed. Никаких регрессий в existing тестах. + +- [ ] **Step VA.4: Run Larastan** + +Run: `cd app && composer stan` +Expected: 0 errors. (Новых файлов мало, pure migrations — должно быть чисто.) + +- [ ] **Step VA.5: Schema diff sanity-check** + +Run: `git diff db/schema.sql | head -200` +Visually verify: все 5 изменений присутствуют, нет случайных правок не относящихся к Plan 1. + +- [ ] **Step VA.6: pgFormatter dry-run** + +Run: `cd app && composer format:sql:check` +Expected: schema.sql отформатирован. + +- [ ] **Step VA.7: Cycle/dependency check** + +Run: `composer dump-autoload -o` +Expected: 0 warnings о круговых зависимостях. + +> **Не двигайся дальше**, пока все шаги VA.1–VA.7 не прошли. Если что-то красное — фикси прямо сейчас, не накапливай. + +--- + +## Task 6: Eloquent model `SupplierProject` + +**Files:** + +- Create: `app/Models/SupplierProject.php` +- Create: `database/factories/SupplierProjectFactory.php` +- Create: `tests/Unit/Models/SupplierProjectTest.php` + +- [ ] **Step 6.1: Write failing model unit test** + +`tests/Unit/Models/SupplierProjectTest.php`: + +```php +create(); + expect($sp->id)->toBeInt()->toBeGreaterThan(0); + expect($sp->platform)->toBeIn(['B1', 'B2', 'B3']); + expect($sp->signal_type)->toBeIn(['site', 'call', 'sms']); +}); + +test('SupplierProject casts current_workdays as array', function () { + $sp = SupplierProject::factory()->create([ + 'current_workdays' => [1, 2, 3, 4, 5], + ]); + + expect($sp->fresh()->current_workdays)->toBe([1, 2, 3, 4, 5]); +}); + +test('SupplierProject casts current_regions as array', function () { + $sp = SupplierProject::factory()->create([ + 'current_regions' => ['77', '78'], + ]); + + expect($sp->fresh()->current_regions)->toBe(['77', '78']); +}); + +test('SupplierProject scopeActive returns only active rows', function () { + SupplierProject::factory()->create(['inactive_since' => null]); + SupplierProject::factory()->create(['inactive_since' => now()->subDays(10)]); + + expect(SupplierProject::active()->count())->toBe(1); +}); + +test('SupplierProject scopeStaleSince returns rows inactive longer than N days', function () { + SupplierProject::factory()->create(['inactive_since' => now()->subDays(200)]); + SupplierProject::factory()->create(['inactive_since' => now()->subDays(100)]); + SupplierProject::factory()->create(['inactive_since' => null]); + + expect(SupplierProject::staleSince(180)->count())->toBe(1); +}); +``` + +- [ ] **Step 6.2: Run failing test** + +`cd app && ./vendor/bin/pest tests/Unit/Models/SupplierProjectTest.php -v` +Expected: failures (class missing). + +- [ ] **Step 6.3: Write model** + +`app/Models/SupplierProject.php`: + +```php + 'array', + 'current_regions' => 'array', + 'current_limit' => 'integer', + 'last_synced_at' => 'datetime', + 'inactive_since' => 'datetime', + ]; + + public function scopeActive(Builder $query): Builder + { + return $query->whereNull('inactive_since'); + } + + public function scopeStaleSince(Builder $query, int $days): Builder + { + return $query->whereNotNull('inactive_since') + ->where('inactive_since', '<=', now()->subDays($days)); + } + + public function scopeForSignal(Builder $query, string $signalType, string $uniqueKey): Builder + { + return $query->where('signal_type', $signalType)->where('unique_key', $uniqueKey); + } + + protected static function newFactory(): SupplierProjectFactory + { + return SupplierProjectFactory::new(); + } +} +``` + +- [ ] **Step 6.4: Write factory** + +`database/factories/SupplierProjectFactory.php`: + +```php + + */ +class SupplierProjectFactory extends Factory +{ + protected $model = SupplierProject::class; + + public function definition(): array + { + $platform = fake()->randomElement(['B1', 'B2', 'B3']); + $signal = fake()->randomElement($platform === 'B1' ? ['site', 'call'] : ['site', 'call', 'sms']); + + return [ + 'platform' => $platform, + 'signal_type' => $signal, + 'unique_key' => fake()->unique()->domainName(), + 'supplier_external_id' => (string) fake()->numberBetween(1_000_000, 99_999_999), + 'current_limit' => 0, + 'current_workdays' => [1, 2, 3, 4, 5, 6, 7], + 'current_regions' => null, + 'sync_status' => 'pending', + 'last_synced_at' => null, + 'inactive_since' => null, + ]; + } +} +``` + +- [ ] **Step 6.5: Run unit tests — verify pass** + +`cd app && ./vendor/bin/pest tests/Unit/Models/SupplierProjectTest.php -v` +Expected: 5 passed. + +- [ ] **Step 6.6: Pint + Larastan** + +Run: `cd app && composer pint && composer stan` +Expected: code-style clean, stan 0 errors. + +- [ ] **Step 6.7: Commit** + +```bash +git add app/app/Models/SupplierProject.php \ + app/database/factories/SupplierProjectFactory.php \ + app/tests/Unit/Models/SupplierProjectTest.php +git commit -m "feat(models): add SupplierProject Eloquent model + factory + unit tests" +``` + +--- + +## Task 7: Eloquent model `PricingTier` + +**Files:** + +- Create: `app/Models/PricingTier.php` +- Create: `database/factories/PricingTierFactory.php` +- Create: `tests/Unit/Models/PricingTierTest.php` + +- [ ] **Step 7.1: Write failing test** + +```php +create(); + expect($t->tier_no)->toBeInt()->toBeBetween(1, 7); + expect($t->price_per_lead_kopecks)->toBeInt()->toBeGreaterThan(0); +}); + +test('priceRubles accessor converts kopecks to rubles', function () { + $t = PricingTier::factory()->create(['price_per_lead_kopecks' => 6000]); + expect($t->price_rubles)->toBe(60.0); +}); + +test('scopeActive returns only is_active=true with effective_from <= today', function () { + PricingTier::factory()->create(['is_active' => true, 'effective_from' => now()->subDay(), 'tier_no' => 1]); + PricingTier::factory()->create(['is_active' => false, 'effective_from' => now()->subDay(), 'tier_no' => 2]); + PricingTier::factory()->create(['is_active' => true, 'effective_from' => now()->addDay(), 'tier_no' => 3]); + + expect(PricingTier::active()->count())->toBe(1); +}); + +test('current() returns today active tier set keyed by tier_no', function () { + foreach (range(1, 7) as $n) { + PricingTier::factory()->create([ + 'tier_no' => $n, + 'is_active' => true, + 'effective_from' => now()->subDay(), + 'leads_in_tier' => $n === 7 ? null : 100, + 'price_per_lead_kopecks' => 7000 - ($n * 500), + ]); + } + + $current = PricingTier::current(); + expect($current)->toHaveCount(7); + expect($current[1]->price_per_lead_kopecks)->toBe(6500); +}); +``` + +- [ ] **Step 7.2: Run failing test** + +`./vendor/bin/pest tests/Unit/Models/PricingTierTest.php -v` +Expected: failures. + +- [ ] **Step 7.3: Write model** + +```php + 'integer', + 'leads_in_tier' => 'integer', + 'price_per_lead_kopecks' => 'integer', + 'is_active' => 'boolean', + 'effective_from' => 'date', + ]; + + public function getPriceRublesAttribute(): float + { + return $this->price_per_lead_kopecks / 100; + } + + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true) + ->where('effective_from', '<=', now()->toDateString()); + } + + /** + * Returns currently active tier-set keyed by tier_no (1..7). + * + * @return Collection + */ + public static function current(): Collection + { + return self::active() + ->orderBy('tier_no') + ->get() + ->keyBy('tier_no'); + } + + protected static function newFactory(): PricingTierFactory + { + return PricingTierFactory::new(); + } +} +``` + +- [ ] **Step 7.4: Write factory** + +```php + + */ +class PricingTierFactory extends Factory +{ + protected $model = PricingTier::class; + + public function definition(): array + { + return [ + 'tier_no' => fake()->numberBetween(1, 7), + 'leads_in_tier' => fake()->randomElement([300, 700, 1000, 2000, 5000, 10000, null]), + 'price_per_lead_kopecks' => fake()->numberBetween(2000, 7000), + 'is_active' => true, + 'effective_from' => now()->toDateString(), + ]; + } +} +``` + +- [ ] **Step 7.5: Run tests — verify pass** + +`./vendor/bin/pest tests/Unit/Models/PricingTierTest.php -v` +Expected: 4 passed. + +- [ ] **Step 7.6: Pint + stan + commit** + +```bash +cd app && composer pint && composer stan +cd .. +git add app/app/Models/PricingTier.php \ + app/database/factories/PricingTierFactory.php \ + app/tests/Unit/Models/PricingTierTest.php +git commit -m "feat(models): add PricingTier model with kopecks→rubles accessor + current() snapshot" +``` + +--- + +## Task 8: Eloquent model `LeadCharge` + +**Files:** + +- Create: `app/Models/LeadCharge.php` +- Create: `database/factories/LeadChargeFactory.php` +- Create: `tests/Unit/Models/LeadChargeTest.php` + +- [ ] **Step 8.1: Write failing test** + +```php +create(); + + DB::transaction(function () use ($tenant) { + DB::statement('SET LOCAL app.current_tenant_id = ?', [$tenant->id]); + + $charge = LeadCharge::factory()->create(['tenant_id' => $tenant->id]); + + expect($charge->tier_no)->toBeInt(); + expect($charge->price_per_lead_kopecks)->toBeInt(); + }); +}); + +test('LeadCharge belongs to tenant', function () { + $tenant = Tenant::factory()->create(); + + DB::transaction(function () use ($tenant) { + DB::statement('SET LOCAL app.current_tenant_id = ?', [$tenant->id]); + + $charge = LeadCharge::factory()->create(['tenant_id' => $tenant->id]); + + expect($charge->tenant)->toBeInstanceOf(Tenant::class); + expect($charge->tenant->id)->toBe($tenant->id); + }); +}); + +test('LeadCharge belongs to deal via composite key', function () { + $tenant = Tenant::factory()->create(); + + DB::transaction(function () use ($tenant) { + DB::statement('SET LOCAL app.current_tenant_id = ?', [$tenant->id]); + + $deal = Deal::factory()->create(['tenant_id' => $tenant->id]); + $charge = LeadCharge::factory()->create([ + 'tenant_id' => $tenant->id, + 'deal_id' => $deal->id, + 'deal_received_at' => $deal->received_at, + ]); + + expect($charge->deal)->toBeInstanceOf(Deal::class); + expect($charge->deal->id)->toBe($deal->id); + }); +}); + +test('priceRubles accessor', function () { + $tenant = Tenant::factory()->create(); + + DB::transaction(function () use ($tenant) { + DB::statement('SET LOCAL app.current_tenant_id = ?', [$tenant->id]); + + $charge = LeadCharge::factory()->create([ + 'tenant_id' => $tenant->id, + 'price_per_lead_kopecks' => 5500, + ]); + + expect($charge->price_rubles)->toBe(55.0); + }); +}); +``` + +- [ ] **Step 8.2: Run failing test** + +Expected: failures. + +- [ ] **Step 8.3: Write model** + +```php + 'integer', + 'price_per_lead_kopecks' => 'integer', + 'deal_received_at' => 'datetime', + 'charged_at' => 'datetime', + 'created_at' => 'datetime', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function deal(): BelongsTo + { + return $this->belongsTo(Deal::class, 'deal_id', 'id'); + } + + public function getPriceRublesAttribute(): float + { + return $this->price_per_lead_kopecks / 100; + } + + protected static function newFactory(): LeadChargeFactory + { + return LeadChargeFactory::new(); + } +} +``` + +- [ ] **Step 8.4: Write factory** + +```php + + */ +class LeadChargeFactory extends Factory +{ + protected $model = LeadCharge::class; + + public function definition(): array + { + return [ + 'tenant_id' => Tenant::factory(), + 'deal_id' => fake()->numberBetween(1, 99999), + 'deal_received_at' => now(), + 'tier_no' => fake()->numberBetween(1, 7), + 'price_per_lead_kopecks' => fake()->numberBetween(2000, 6000), + 'charged_at' => now(), + 'created_at' => now(), + ]; + } +} +``` + +> **Note:** factory НЕ создаёт реальный Deal из-за партиционированной таблицы; тесты, которым нужен реальный FK, явно создают `Deal::factory()` отдельно. + +- [ ] **Step 8.5: Run tests, pint, stan, commit** + +```bash +cd app && ./vendor/bin/pest tests/Unit/Models/LeadChargeTest.php -v +composer pint && composer stan +cd .. +git add app/app/Models/LeadCharge.php \ + app/database/factories/LeadChargeFactory.php \ + app/tests/Unit/Models/LeadChargeTest.php +git commit -m "feat(models): add LeadCharge ledger model + factory + relations to Tenant/Deal" +``` + +--- + +## Task 9: Eloquent model `SupplierSyncLog` + +**Files:** + +- Create: `app/Models/SupplierSyncLog.php` +- Create: `database/factories/SupplierSyncLogFactory.php` +- Create: `tests/Unit/Models/SupplierSyncLogTest.php` + +- [ ] **Step 9.1: Write test** + +```php +create(); + expect($log->action)->toBeIn(['create', 'update', 'delete', 'disable', 'session_refresh']); +}); + +test('SupplierSyncLog has nullable supplier_project relation', function () { + $sp = SupplierProject::factory()->create(); + $log = SupplierSyncLog::factory()->create(['supplier_project_id' => $sp->id]); + + expect($log->supplierProject)->toBeInstanceOf(SupplierProject::class); + expect($log->supplierProject->id)->toBe($sp->id); +}); + +test('SupplierSyncLog request_payload and response_body cast as array', function () { + $log = SupplierSyncLog::factory()->create([ + 'request_payload' => ['name' => 'X'], + 'response_body' => ['status' => 'OK'], + ]); + + expect($log->fresh()->request_payload)->toBe(['name' => 'X']); + expect($log->fresh()->response_body)->toBe(['status' => 'OK']); +}); +``` + +- [ ] **Step 9.2: Run failing** + +Expected: failures. + +- [ ] **Step 9.3: Write model** + +```php + 'array', + 'response_body' => 'array', + 'http_status' => 'integer', + 'duration_ms' => 'integer', + 'created_at' => 'datetime', + ]; + + public function supplierProject(): BelongsTo + { + return $this->belongsTo(SupplierProject::class); + } + + protected static function newFactory(): SupplierSyncLogFactory + { + return SupplierSyncLogFactory::new(); + } +} +``` + +- [ ] **Step 9.4: Write factory** + +```php + + */ +class SupplierSyncLogFactory extends Factory +{ + protected $model = SupplierSyncLog::class; + + public function definition(): array + { + return [ + 'supplier_project_id' => null, + 'action' => fake()->randomElement(['create', 'update', 'delete', 'disable', 'session_refresh']), + 'request_payload' => ['stub' => true], + 'response_body' => null, + 'http_status' => 200, + 'error_message' => null, + 'duration_ms' => fake()->numberBetween(50, 5000), + 'created_at' => now(), + ]; + } +} +``` + +- [ ] **Step 9.5: Run tests + pint + stan + commit** + +```bash +cd app && ./vendor/bin/pest tests/Unit/Models/SupplierSyncLogTest.php -v +composer pint && composer stan +cd .. +git add app/app/Models/SupplierSyncLog.php \ + app/database/factories/SupplierSyncLogFactory.php \ + app/tests/Unit/Models/SupplierSyncLogTest.php +git commit -m "feat(models): add SupplierSyncLog model + factory (audit trail для AJAX-sync)" +``` + +--- + +## Task 10: Extend `Project` model + +**Why:** Существующая `Project` сейчас без полей signal_*, sms_*, delivered_in_month, supplier_b{1,2,3}_project_id. Нужно добавить fillable + casts + relationships + accessors. + +**Files:** + +- Modify: `app/Models/Project.php` +- Modify: `database/factories/ProjectFactory.php` +- Create: `tests/Unit/Models/ProjectExtensionsTest.php` + +- [ ] **Step 10.1: Write failing test** + +```php +create(); + + DB::transaction(function () use ($tenant) { + DB::statement('SET LOCAL app.current_tenant_id = ?', [$tenant->id]); + + $p = Project::factory()->create([ + 'tenant_id' => $tenant->id, + 'signal_type' => 'site', + 'signal_identifier' => 'example.com', + ]); + + expect($p->signal_type)->toBe('site'); + expect($p->signal_identifier)->toBe('example.com'); + }); +}); + +test('Project casts sms_senders as array', function () { + $tenant = Tenant::factory()->create(); + + DB::transaction(function () use ($tenant) { + DB::statement('SET LOCAL app.current_tenant_id = ?', [$tenant->id]); + + $p = Project::factory()->create([ + 'tenant_id' => $tenant->id, + 'signal_type' => 'sms', + 'sms_senders' => ['TINKOFF', 'SBERBANK'], + 'sms_keyword' => 'ипотека', + ]); + + expect($p->fresh()->sms_senders)->toBe(['TINKOFF', 'SBERBANK']); + expect($p->fresh()->sms_keyword)->toBe('ипотека'); + }); +}); + +test('Project has supplierB1/B2/B3 relations', function () { + $tenant = Tenant::factory()->create(); + + DB::transaction(function () use ($tenant) { + DB::statement('SET LOCAL app.current_tenant_id = ?', [$tenant->id]); + + $sp = SupplierProject::factory()->create(['platform' => 'B1']); + $p = Project::factory()->create([ + 'tenant_id' => $tenant->id, + 'supplier_b1_project_id' => $sp->id, + ]); + + expect($p->supplierB1)->toBeInstanceOf(SupplierProject::class); + expect($p->supplierB1->id)->toBe($sp->id); + }); +}); + +test('Project scopeActiveOnDay returns projects with today in delivery_days_mask', function () { + $tenant = Tenant::factory()->create(); + + DB::transaction(function () use ($tenant) { + DB::statement('SET LOCAL app.current_tenant_id = ?', [$tenant->id]); + + // delivery_days_mask: bit 0..6 для Пн..Вс + // Проект на все дни: + Project::factory()->create([ + 'tenant_id' => $tenant->id, + 'is_active' => true, + 'delivery_days_mask' => 0b1111111, + ]); + // Только выходные: + Project::factory()->create([ + 'tenant_id' => $tenant->id, + 'is_active' => true, + 'delivery_days_mask' => 0b1100000, + ]); + + $todayDow = (int) now()->dayOfWeekIso; // 1..7 + + $count = Project::activeOnDay($todayDow)->count(); + expect($count)->toBeGreaterThanOrEqual(1); + }); +}); +``` + +- [ ] **Step 10.2: Run failing test** + +Expected: failures (методы/relations отсутствуют). + +- [ ] **Step 10.3: Modify Project model** + +Открой `app/Models/Project.php` и добавь: + +В `$fillable` (если используется): + +```php +'signal_type', +'signal_identifier', +'sms_senders', +'sms_keyword', +'delivered_in_month', +'supplier_b1_project_id', +'supplier_b2_project_id', +'supplier_b3_project_id', +``` + +В `$casts`: + +```php +'sms_senders' => 'array', +'delivered_in_month' => 'integer', +``` + +Добавь методы: + +```php +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Relations\BelongsTo; + +public function supplierB1(): BelongsTo +{ + return $this->belongsTo(SupplierProject::class, 'supplier_b1_project_id'); +} + +public function supplierB2(): BelongsTo +{ + return $this->belongsTo(SupplierProject::class, 'supplier_b2_project_id'); +} + +public function supplierB3(): BelongsTo +{ + return $this->belongsTo(SupplierProject::class, 'supplier_b3_project_id'); +} + +public function scopeActiveOnDay(Builder $query, int $isoDayOfWeek): Builder +{ + $bit = 1 << ($isoDayOfWeek - 1); + return $query->where('is_active', true) + ->whereRaw('(delivery_days_mask & ?) <> 0', [$bit]); +} + +public function scopeForSignal(Builder $query, string $signalType, string $identifier): Builder +{ + return $query->where('signal_type', $signalType)->where('signal_identifier', $identifier); +} +``` + +Добавь imports `use App\Models\SupplierProject;` если ещё нет. + +- [ ] **Step 10.4: Update `ProjectFactory`** + +Добавь в `definition()`: + +```php +'signal_type' => null, +'signal_identifier' => null, +'sms_senders' => null, +'sms_keyword' => null, +'delivered_in_month' => 0, +'supplier_b1_project_id' => null, +'supplier_b2_project_id' => null, +'supplier_b3_project_id' => null, +``` + +Добавь state-метод: + +```php +public function asSiteSignal(string $domain): self +{ + return $this->state([ + 'signal_type' => 'site', + 'signal_identifier' => $domain, + ]); +} + +public function asCallSignal(string $phone): self +{ + return $this->state([ + 'signal_type' => 'call', + 'signal_identifier' => $phone, + ]); +} + +public function asSmsSignal(array $senders, ?string $keyword = null): self +{ + return $this->state([ + 'signal_type' => 'sms', + 'signal_identifier' => null, + 'sms_senders' => $senders, + 'sms_keyword' => $keyword, + ]); +} +``` + +- [ ] **Step 10.5: Run tests — pass** + +`./vendor/bin/pest tests/Unit/Models/ProjectExtensionsTest.php -v` +Expected: 4 passed. + +- [ ] **Step 10.6: Pint, stan, full Pest baseline** + +```bash +cd app +composer pint +composer stan +composer test +``` + +Expected: всё зелёное, 403+новые passed (никаких регрессий в existing тестах после правки `Project`). + +- [ ] **Step 10.7: Commit** + +```bash +git add app/app/Models/Project.php \ + app/database/factories/ProjectFactory.php \ + app/tests/Unit/Models/ProjectExtensionsTest.php +git commit -m "feat(models): extend Project with signal_type, sms_senders, supplier_b1/b2/b3 relations + scopes" +``` + +--- + +## ✓ Verification Gate B — Models & Factories + +- [ ] **Step VB.1: Run Unit/Models tests** + +`cd app && ./vendor/bin/pest tests/Unit/Models/ -v` +Expected: все passed. + +- [ ] **Step VB.2: Run full baseline** + +`cd app && composer test` +Expected: 403 baseline + новые passed, 0 регрессий. + +- [ ] **Step VB.3: Larastan** + +`cd app && composer stan` +Expected: 0 errors. Если появились ошибки на новых моделях — типы PHPDoc'ов поправь в моделях/фабриках. + +- [ ] **Step VB.4: Pint** + +`cd app && composer pint -- --test` +Expected: clean. + +- [ ] **Step VB.5: Cross-model relation sanity-check** + +Mini-script (можно добавить как отдельный test или прогнать в `php artisan tinker`): + +```php +$tenant = \App\Models\Tenant::factory()->create(); +DB::transaction(function () use ($tenant) { + DB::statement('SET LOCAL app.current_tenant_id = ?', [$tenant->id]); + $sp = \App\Models\SupplierProject::factory()->create(['platform' => 'B1']); + $p = \App\Models\Project::factory()->create([ + 'tenant_id' => $tenant->id, + 'signal_type' => 'site', + 'signal_identifier' => 'a.com', + 'supplier_b1_project_id' => $sp->id, + ]); + dump($p->supplierB1->platform); // 'B1' +}); +``` + +Ожидание: 'B1' в выводе. + +--- + +## Task 11: SupplierProject ↔ Project linkage helper service + +**Why:** Логика «найти существующий supplier_project или создать стаб» нужна в нескольких местах (Project create, supplier sync). Чтобы избежать циклической зависимости и дублирования — выносим в Service. + +**Files:** + +- Create: `app/Services/SupplierProjects/SupplierProjectResolver.php` +- Create: `tests/Unit/Services/SupplierProjectResolverTest.php` + +- [ ] **Step 11.1: Write failing test** + +```php +create([ + 'platform' => 'B1', + 'signal_type' => 'site', + 'unique_key' => 'example.com', + ]); + + $resolver = new SupplierProjectResolver(); + $resolved = $resolver->resolveOrStub('B1', 'site', 'example.com'); + + expect($resolved->id)->toBe($existing->id); +}); + +test('resolveOrStub creates pending stub when no existing project', function () { + $resolver = new SupplierProjectResolver(); + $resolved = $resolver->resolveOrStub('B2', 'call', '79991234567'); + + expect($resolved->exists)->toBeTrue(); + expect($resolved->platform)->toBe('B2'); + expect($resolved->signal_type)->toBe('call'); + expect($resolved->unique_key)->toBe('79991234567'); + expect($resolved->sync_status)->toBe('pending'); +}); + +test('resolveOrStub returns same row on second call (no duplicates)', function () { + $resolver = new SupplierProjectResolver(); + $first = $resolver->resolveOrStub('B3', 'sms', 'TINKOFF'); + $second = $resolver->resolveOrStub('B3', 'sms', 'TINKOFF'); + + expect($first->id)->toBe($second->id); + expect(SupplierProject::where('unique_key', 'TINKOFF')->count())->toBe(1); +}); + +test('resolveOrStub throws DomainException for B1+sms (forbidden combo)', function () { + $resolver = new SupplierProjectResolver(); + expect(fn () => $resolver->resolveOrStub('B1', 'sms', 'TINKOFF')) + ->toThrow(DomainException::class); +}); + +test('resolveOrStub throws InvalidArgumentException for invalid platform', function () { + $resolver = new SupplierProjectResolver(); + expect(fn () => $resolver->resolveOrStub('B9', 'site', 'a.com')) + ->toThrow(InvalidArgumentException::class); +}); +``` + +- [ ] **Step 11.2: Run failing test** + +Expected: failures. + +- [ ] **Step 11.3: Write service** + +```php + $platform, 'unique_key' => $uniqueKey], + [ + 'signal_type' => $signalType, + 'sync_status' => 'pending', + 'current_limit' => 0, + 'current_workdays' => [1, 2, 3, 4, 5, 6, 7], + ] + ); + } +} +``` + +- [ ] **Step 11.4: Run tests, pint, stan** + +`./vendor/bin/pest tests/Unit/Services/SupplierProjectResolverTest.php -v` +Expected: 5 passed. + +`composer pint && composer stan` + +- [ ] **Step 11.5: Commit** + +```bash +git add app/app/Services/SupplierProjects/SupplierProjectResolver.php \ + app/tests/Unit/Services/SupplierProjectResolverTest.php +git commit -m "feat(services): add SupplierProjectResolver (resolveOrStub with B1+SMS guard)" +``` + +--- + +## Task 12: Validation rules — Signal validators + +**Why:** §3.1 спеки — формат валидации. Выносим в отдельные Rule-классы для переиспользования (FormRequest на следующем плане + service-level вызов). + +**Files:** + +- Create: `app/Rules/SignalIdentifier/DomainIdentifier.php` +- Create: `app/Rules/SignalIdentifier/PhoneIdentifier.php` +- Create: `app/Rules/SignalIdentifier/SmsSenderRule.php` +- Create: `tests/Unit/Rules/SignalValidatorsTest.php` + +- [ ] **Step 12.1: Write failing test** + +```php + $value], ['x' => [new DomainIdentifier()]]); + expect($v->fails())->toBeFalse("rejected valid {$value}"); +})->with('domains_valid'); + +test('DomainIdentifier rejects invalid', function (string $value) { + $v = Validator::make(['x' => $value], ['x' => [new DomainIdentifier()]]); + expect($v->fails())->toBeTrue("accepted invalid {$value}"); +})->with('domains_invalid'); + +dataset('phones_valid', [ + ['79991234567'], + ['74955551212'], +]); + +dataset('phones_invalid', [ + ['89991234567'], // not 7-prefix + ['7999123456'], // 10 digits + ['799912345678'], // 12 digits + ['+79991234567'], // plus + ['7 999 123 45 67'], // spaces +]); + +test('PhoneIdentifier accepts valid', function (string $v) { + $val = Validator::make(['x' => $v], ['x' => [new PhoneIdentifier()]]); + expect($val->fails())->toBeFalse(); +})->with('phones_valid'); + +test('PhoneIdentifier rejects invalid', function (string $v) { + $val = Validator::make(['x' => $v], ['x' => [new PhoneIdentifier()]]); + expect($val->fails())->toBeTrue(); +})->with('phones_invalid'); + +test('SmsSenderRule accepts alpha and short numeric', function () { + foreach (['TINKOFF', 'SBER', '900', '1234', 'BANK-1'] as $v) { + $r = Validator::make(['x' => $v], ['x' => [new SmsSenderRule()]]); + expect($r->fails())->toBeFalse("rejected valid {$v}"); + } +}); + +test('SmsSenderRule rejects 11-digit phone (supplier blocks it)', function () { + foreach (['79991234567', '12345678901'] as $v) { + $r = Validator::make(['x' => $v], ['x' => [new SmsSenderRule()]]); + expect($r->fails())->toBeTrue("accepted invalid {$v}"); + } +}); + +test('SmsSenderRule rejects too long', function () { + $longString = str_repeat('A', 31); + $r = Validator::make(['x' => $longString], ['x' => [new SmsSenderRule()]]); + expect($r->fails())->toBeTrue(); +}); +``` + +- [ ] **Step 12.2: Run failing** + +Expected: failures. + +- [ ] **Step 12.3: Write rules** + +`app/Rules/SignalIdentifier/DomainIdentifier.php`: + +```php + 30) { + $fail('SMS sender must be 1–30 characters.'); + return; + } + + if (!preg_match('/^[A-Za-z0-9_-]+$/', $value)) { + $fail('SMS sender must contain only letters, digits, underscore or hyphen.'); + return; + } + + // Reject 11-digit phone numbers (supplier blocks them) + if (preg_match('/^\d{11}$/', $value)) { + $fail('SMS sender cannot be an 11-digit phone number (supplier rejection).'); + } + } +} +``` + +- [ ] **Step 12.4: Run tests + pint + stan** + +```bash +cd app +./vendor/bin/pest tests/Unit/Rules/SignalValidatorsTest.php -v +composer pint && composer stan +``` + +Expected: все тесты passed, 0 stan errors. + +- [ ] **Step 12.5: Commit** + +```bash +git add app/app/Rules/SignalIdentifier/DomainIdentifier.php \ + app/app/Rules/SignalIdentifier/PhoneIdentifier.php \ + app/app/Rules/SignalIdentifier/SmsSenderRule.php \ + app/tests/Unit/Rules/SignalValidatorsTest.php +git commit -m "feat(rules): add Signal validators (Domain, Phone, SmsSender) with comprehensive datasets" +``` + +--- + +## ✓ Comprehensive Verification Gate (END OF PLAN 1) + +> Это финальный gate. Без зелёного прохода всех 14 шагов — план НЕ закрыт. + +- [ ] **Step CV.1: Fresh migration from scratch** + +```bash +cd app +php artisan migrate:fresh +``` + +Expected: все миграции с нуля прошли. + +- [ ] **Step CV.2: Full Pest baseline** + +`cd app && composer test` +Expected: ВСЕ существующие 403 + добавленные тесты passed. **0 регрессий.** + +- [ ] **Step CV.3: Larastan full** + +`cd app && composer stan` +Expected: 0 errors. + +- [ ] **Step CV.4: Pint full project** + +`cd app && composer pint -- --test` +Expected: clean. + +- [ ] **Step CV.5: squawk на все новые миграции** + +```bash +cd app +for m in database/migrations/2026_05_10_*.php; do + npx --yes squawk "$m" || echo "❌ $m" +done +``` + +Expected: 0 issues per migration. + +- [ ] **Step CV.6: pgFormatter dry-run** + +`cd app && composer format:sql:check` +Expected: clean. + +- [ ] **Step CV.7: cspell на все .md изменения** + +`npm run spell` (в корне репозитория) +Expected: 0 unknown words. Если есть — добавить в `cspell-words.txt` (новые валидные термины), либо исправить опечатки. + +- [ ] **Step CV.8: markdownlint** + +`npm run lint:md` +Expected: 0 errors. + +- [ ] **Step CV.9: Cycle check** + +`cd app && composer dump-autoload -o` +Expected: 0 warnings (особенно про circular references между models/services). + +- [ ] **Step CV.10: Schema diff manual review** + +`git diff db/schema.sql` +Verify (visual): + +- 5 новых тиблов/расширений присутствуют (3 CREATE TABLE + 1 ALTER TABLE projects + не забыли ничего ещё) +- Все CHECK-constraints читаемы, без опечаток имён колонок +- RLS включён ровно на `lead_charges` +- REVOKE для SaaS-таблиц +- Версия в шапке схемы bumped (v8.11 → v8.13) + +- [ ] **Step CV.11: Relations sanity in tinker** + +```bash +cd app && php artisan tinker +``` + +В тинкере: + +```php +use App\Models\{Tenant, Project, SupplierProject, LeadCharge, PricingTier, SupplierSyncLog}; +$t = Tenant::factory()->create(); +DB::transaction(function () use ($t) { + DB::statement('SET LOCAL app.current_tenant_id = ?', [$t->id]); + $sp = SupplierProject::factory()->create(['platform' => 'B1']); + $p = Project::factory()->create(['tenant_id' => $t->id, 'supplier_b1_project_id' => $sp->id]); + echo $p->supplierB1->platform . "\n"; // B1 + echo $p->signal_type ?? 'null' . "\n"; // null (default) + $pt = PricingTier::factory()->create(); + echo $pt->price_rubles . "\n"; // float + $log = SupplierSyncLog::factory()->create(['supplier_project_id' => $sp->id]); + echo $log->supplierProject->id === $sp->id ? "log->sp OK\n" : "FAIL\n"; +}); +``` + +Expected: все строки выводятся корректно. + +- [ ] **Step CV.12: Code review subagent** + +Dispatch subagent: + +``` +Agent({ + description: "Code review supplier foundation", + subagent_type: "general-purpose", + prompt: "Code-review changes in commits since `` on branch in c:\моя\проекты\портал crm\Документация\. + + Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md (sections §2, §7) + Plan: docs/superpowers/plans/2026-05-10-supplier-foundation-plan.md + + Verify: + 1. Migrations — наличие всех CHECK constraints из спеки (B1+SMS forbidden, sms_senders required if signal_type=sms, etc.) + 2. Models — фабрики не создают forbidden combos (B1+SMS), relationships определены, casts соответствуют DDL + 3. Validators — datasets покрывают всё что в §3.1 спеки (домен/номер/sms-sender) + 4. RLS — lead_charges ENABLE+FORCE+POLICY+GRANT, supplier-side таблицы REVOKE + 5. SQL safety — squawk-relevant: нет dangerous DDL без CONCURRENTLY, нет default values for added columns в больших таблицах (projects может быть large) + 6. Опечатки — особенно в SQL identifiers/CHECK constraint names + + Report: что найдено, чего не хватает, и какие потенциальные баги. Под 400 слов." +}) +``` + +После review — поправить найденное, повторить gate. + +- [ ] **Step CV.13: Update Tooling reference + project state memory** + +Если в этом плане появились новые npm/composer пакеты — обновить `docs/Tooling_v8_3.md`. Если изменились квирки окружения — добавить в `memory/feedback_environment.md`. + +В этом плане новых пакетов нет — пропускай шаг. + +- [ ] **Step CV.14: Final commit (CHANGELOG + plan checkbox closure)** + +Если все шаги CV.1–CV.13 зелёные: + +```bash +cd "c:/моя/проекты/портал crm/Документация" +# Mark plan as complete in its header (Status field) если есть +git add docs/superpowers/plans/2026-05-10-supplier-foundation-plan.md +git commit -m "docs(plans): close Plan 1 — supplier integration foundation (schema + models + validators)" +git push origin main # или branch если в worktree +``` + +--- + +## Definition of Done (Plan 1) + +- ✅ 5 миграций применяются на чистую БД, откатываются обратно +- ✅ 4 новые модели с factories, accessors, scopes +- ✅ Project model расширен, существующие тесты не сломаны +- ✅ 3 валидатора (Domain/Phone/SmsSender) с dataset-тестами на edge cases +- ✅ Сервис `SupplierProjectResolver` с B1+SMS guard +- ✅ RLS на `lead_charges` работает (cross-tenant isolation тест) +- ✅ db/schema.sql + CHANGELOG_schema.md синхронизированы +- ✅ 0 регрессий: 403 baseline + новые passed +- ✅ Larastan / Pint / squawk / pgFormatter / cspell / markdownlint — все чистые +- ✅ Code review subagent прошёл без блокеров + +После закрытия Plan 1 — переходим к Plan 2 (Webhook + Sharing Routing). + +--- + +## Notes & quirks + +- **kopecks vs decimal:** все money-поля integer kopecks. Float — только в accessors на чтение. +- **Composite FK:** `lead_charges.deal_id, deal_received_at` → `deals(id, received_at)` через DEFERRABLE INITIALLY DEFERRED — потому что при INSERT deal+charge в одной транзакции composite FK иначе не пройдёт. +- **Existing Project поля:** `tag`, `daily_limit_target`, `region_mask`, `delivery_days_mask`, `is_active`, `effective_daily_limit_today` уже есть. Не дублируем — переиспользуем. `region_mask` — это битовая маска (uint64), не jsonb. Соответствие "regions из спеки" обеспечит mapper в Plan 2. +- **delivery_days_mask:** битовая маска bit 0 = понедельник, bit 6 = воскресенье (ISO day-of-week минус 1). `scopeActiveOnDay($iso)` использует `1 << ($iso - 1)`. +- **Существующий webhook payload:** в Plan 2 Будем парсить `B1_vashinvestor.ru` → platform=B1, identifier=vashinvestor.ru. Адаптация существующего `ProcessWebhookJob`. +- **`SetTenantContext` middleware:** применяется автоматически на роутах под `tenant` alias. Тесты с RLS — внутри `DB::transaction()` с явным `SET LOCAL`. + +--- + +## Self-review checklist (executed by author at write-time) + +- [x] **Spec coverage:** §2.1 (Project fields) — Task 1+10. §2.2 (supplier_projects) — Task 2+6. §7 (billing) — Task 3+8 (pricing_tiers + lead_charges). §3.1 (validators) — Task 12. §4.3 (sync_log) — Task 5+9. ✓ +- [x] **Placeholder scan:** нет TBD/TODO/«implement later» в коде, везде указаны точные пути и команды. ✓ +- [x] **Type consistency:** `signal_type` enum 'site'/'call'/'sms' одинаков в DDL CHECK + ProjectFactory + Validators + Resolver. `platform` enum 'B1'/'B2'/'B3' одинаков везде. `tier_no` integer 1..7 одинаков. ✓