# G1/SP2 — реквизиты + ИНН/DaData + гейт первого проекта — Implementation Plan

> **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.

**Goal:** Дать клиенту завести минимальные реквизиты (тип лица + контакт, для ИП/юр — ИНН с мягкой DaData-подтяжкой), хранить их под будущее дозаполнение в ЛК и не давать создать первый проект до заполнения минимума.

**Architecture:** Новая 1:1 таблица `tenant_requisites` (RLS по tenant_id). Чистые хелперы `PhoneNormalizer`/`InnValidator`. Сервис `RequisitesService` (upsert + `isLightComplete`). DaData party-клиент за интерфейсом `PartyLookup` с Null-драйвером по умолчанию (мягко, не блокирует). Эндпоинты `GET/PUT /api/tenant/requisites` + `POST /lookup-inn`. Гейт в `ProjectController::store()`.

**Tech Stack:** PHP 8.3 / Laravel 13 / PostgreSQL 16 (RLS) / Pest 4. Тест-БД `liderra_testing`.

**Спека:** `docs/superpowers/specs/2026-06-18-g1-sp2-requisites-gate-spec-v1.md`.

**Рабочий режим:** реализация под стеной «роутер-наставник» через **per-step escape** (каждый Write/Edit/Bash — кнопкой `FLOOR-ESCAPE: <canonicalAction>`, батч 3-4 в карточке). Финал (pint + миграция dev-БД + commit) — терминал владельца.

**Команды тестов:**
- Unit: `composer --working-dir=app test -- tests/Unit/<...>`
- Feature: `composer --working-dir=app test -- tests/Feature/<...>`
- Применить миграцию к тест-БД (раз, перед feature-тестами): `DB_DATABASE=liderra_testing php app/artisan migrate --force`
- Регрессия tools (по необходимости): см. CLAUDE.md §4.

---

## Карта файлов

**Создать:**
- `app/app/Support/PhoneNormalizer.php`
- `app/app/Support/InnValidator.php`
- `app/database/migrations/2026_06_18_140000_create_tenant_requisites.php`
- `app/app/Models/TenantRequisites.php`
- `app/app/Services/DaData/PartyLookup.php` (интерфейс)
- `app/app/Services/DaData/Dto/PartyLookupResult.php`
- `app/app/Services/DaData/NullPartyLookup.php`
- `app/app/Services/DaData/DaDataPartyClient.php`
- `app/app/Services/Requisites/RequisitesService.php`
- `app/app/Http/Requests/UpdateRequisitesRequest.php`
- `app/app/Http/Requests/LookupInnRequest.php`
- `app/app/Http/Resources/RequisitesResource.php`
- `app/app/Http/Controllers/Api/TenantRequisitesController.php`
- Тесты: `tests/Unit/Support/PhoneNormalizerTest.php`, `tests/Unit/Support/InnValidatorTest.php`, `tests/Feature/Services/DaData/DaDataPartyClientTest.php`, `tests/Feature/Requisites/RequisitesTest.php`, `tests/Feature/Requisites/TenantRequisitesLookupTest.php`, `tests/Feature/Requisites/ProjectGateTest.php`

**Изменить:**
- `db/schema.sql` (+ `CREATE TABLE tenant_requisites` + RLS-политика + в список ENABLE RLS), `db/CHANGELOG_schema.md`
- `app/app/Models/Tenant.php` (relation `requisites()`)
- `app/config/services.php` (блок `dadata` + `party_enabled`)
- `app/app/Providers/AppServiceProvider.php` (биндинг `PartyLookup`)
- `app/app/Http/Controllers/Api/ProjectController.php` (гейт в `store()` + DI `RequisitesService`)
- `app/routes/web.php` (группа `/api/tenant/requisites`)

---

## Task 1: PhoneNormalizer (чистый хелпер, TDD)

**Files:**
- Create: `app/app/Support/PhoneNormalizer.php`
- Test: `app/tests/Unit/Support/PhoneNormalizerTest.php`

- [ ] **Step 1: Написать падающий тест**

```php
<?php

declare(strict_types=1);

use App\Support\PhoneNormalizer;

it('normalizes russian phones to +7XXXXXXXXXX', function (string $input, ?string $expected) {
    expect(PhoneNormalizer::normalize($input))->toBe($expected);
})->with([
    ['+7 (915) 123-45-67', '+79151234567'],
    ['8 915 123 45 67',    '+79151234567'],
    ['9151234567',         '+79151234567'],
    ['7(915)1234567',      '+79151234567'],
    ['123',                null],
    ['+1 202 555 0143',    null],   // 11 цифр, но начинается не с 7/8
    ['',                   null],
]);
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `composer --working-dir=app test -- tests/Unit/Support/PhoneNormalizerTest.php`
Expected: FAIL — class `App\Support\PhoneNormalizer` not found.

- [ ] **Step 3: Реализовать**

```php
<?php

declare(strict_types=1);

namespace App\Support;

final class PhoneNormalizer
{
    /**
     * Нормализует российский номер к виду +7XXXXXXXXXX (12 символов) или null, если невалиден.
     */
    public static function normalize(string $raw): ?string
    {
        $digits = preg_replace('/\D+/', '', $raw) ?? '';

        if (strlen($digits) === 11 && ($digits[0] === '8' || $digits[0] === '7')) {
            $digits = '7'.substr($digits, 1);
        } elseif (strlen($digits) === 10) {
            $digits = '7'.$digits;
        } else {
            return null;
        }

        return '+'.$digits;
    }
}
```

- [ ] **Step 4: Запустить — убедиться, что проходит**

Run: `composer --working-dir=app test -- tests/Unit/Support/PhoneNormalizerTest.php`
Expected: PASS (7 кейсов).

- [ ] **Step 5: Commit** (терминал владельца) — отложить до конца, см. Task 9.

---

## Task 2: InnValidator (чистый хелпер, TDD)

**Files:**
- Create: `app/app/Support/InnValidator.php`
- Test: `app/tests/Unit/Support/InnValidatorTest.php`

- [ ] **Step 1: Написать падающий тест**

```php
<?php

declare(strict_types=1);

use App\Support\InnValidator;

it('validates 10-digit legal entity INN by checksum', function () {
    expect(InnValidator::isValid('7707083893', 'legal_entity'))->toBeTrue();   // Сбербанк
    expect(InnValidator::isValid('7707083890', 'legal_entity'))->toBeFalse();  // битая контр. цифра
    expect(InnValidator::isValid('770708389', 'legal_entity'))->toBeFalse();   // 9 цифр
});

it('validates 12-digit sole proprietor INN by two checksums', function () {
    expect(InnValidator::isValid('500100732259', 'sole_proprietor'))->toBeTrue();
    expect(InnValidator::isValid('500100732250', 'sole_proprietor'))->toBeFalse();
    expect(InnValidator::isValid('50010073225', 'sole_proprietor'))->toBeFalse(); // 11 цифр
});

it('does not require INN for individuals', function () {
    expect(InnValidator::isValid('', 'individual'))->toBeTrue();
});

it('rejects non-digit input', function () {
    expect(InnValidator::isValid('77070838AB', 'legal_entity'))->toBeFalse();
});
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `composer --working-dir=app test -- tests/Unit/Support/InnValidatorTest.php`
Expected: FAIL — class not found.

- [ ] **Step 3: Реализовать**

```php
<?php

declare(strict_types=1);

namespace App\Support;

final class InnValidator
{
    public static function isValid(string $inn, string $subjectType): bool
    {
        if ($subjectType === 'individual') {
            return true; // физлицу ИНН не требуется (SP2)
        }
        if (! ctype_digit($inn)) {
            return false;
        }

        return match ($subjectType) {
            'legal_entity' => self::valid10($inn),
            'sole_proprietor' => self::valid12($inn),
            default => false,
        };
    }

    private static function valid10(string $inn): bool
    {
        if (strlen($inn) !== 10) {
            return false;
        }

        return self::checksum($inn, [2, 4, 10, 3, 5, 9, 4, 6, 8]) === (int) $inn[9];
    }

    private static function valid12(string $inn): bool
    {
        if (strlen($inn) !== 12) {
            return false;
        }

        return self::checksum($inn, [7, 2, 4, 10, 3, 5, 9, 4, 6, 8]) === (int) $inn[10]
            && self::checksum($inn, [3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8]) === (int) $inn[11];
    }

    /** @param  int[]  $weights */
    private static function checksum(string $inn, array $weights): int
    {
        $sum = 0;
        foreach ($weights as $i => $w) {
            $sum += $w * (int) $inn[$i];
        }

        return ($sum % 11) % 10;
    }
}
```

- [ ] **Step 4: Запустить — убедиться, что проходит**

Run: `composer --working-dir=app test -- tests/Unit/Support/InnValidatorTest.php`
Expected: PASS.

- [ ] **Step 5: Commit** — отложить (Task 9).

---

## Task 3: Миграция + schema.sql + CHANGELOG

**Files:**
- Create: `app/database/migrations/2026_06_18_140000_create_tenant_requisites.php`
- Modify: `db/schema.sql`, `db/CHANGELOG_schema.md`

- [ ] **Step 1: Написать миграцию**

```php
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;

return new class extends Migration
{
    public function up(): void
    {
        // DDL через pgsql_supplier (урок SP1: prod-роли + избегаем deadlock смешивания соединений).
        // На dev/testing pgsql_supplier = postgres superuser, на prod — crm_supplier_worker.
        $supplier = DB::connection('pgsql_supplier');

        $supplier->unprepared(<<<'SQL'
            CREATE TABLE IF NOT EXISTS tenant_requisites (
                id                       BIGSERIAL PRIMARY KEY,
                tenant_id                BIGINT NOT NULL UNIQUE REFERENCES tenants(id) ON DELETE CASCADE,
                subject_type             VARCHAR(20) NOT NULL
                                         CHECK (subject_type IN ('individual','sole_proprietor','legal_entity')),
                contact_name             VARCHAR(255) NOT NULL,
                contact_phone            VARCHAR(16)  NOT NULL,
                inn                      VARCHAR(12),
                legal_name               VARCHAR(255),
                kpp                      VARCHAR(9),
                ogrn                     VARCHAR(15),
                legal_address            TEXT,
                bank_name                VARCHAR(255),
                bank_bik                 VARCHAR(9),
                bank_account             VARCHAR(20),
                corr_account             VARCHAR(20),
                dadata_raw               JSONB,
                dadata_synced_at         TIMESTAMPTZ,
                requisites_completed_at  TIMESTAMPTZ,
                created_at               TIMESTAMPTZ NOT NULL DEFAULT NOW(),
                updated_at               TIMESTAMPTZ
            );

            ALTER TABLE tenant_requisites ENABLE ROW LEVEL SECURITY;
            CREATE POLICY tenant_requisites_tenant_isolation
                ON tenant_requisites
                USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint)
                WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::bigint);
        SQL);

        // GRANT-ы под прод-роли (на dev — superuser, роли могут отсутствовать → guard по pg_roles).
        foreach (['crm_app_user', 'crm_supplier_worker', 'crm_admin_user'] as $role) {
            $grant = $role === 'crm_supplier_worker'
                ? 'SELECT, INSERT, UPDATE, DELETE'
                : 'SELECT, INSERT, UPDATE';
            $supplier->statement(<<<SQL
                DO \$\$
                BEGIN
                    IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{$role}') THEN
                        GRANT {$grant} ON tenant_requisites TO {$role};
                        GRANT USAGE, SELECT ON SEQUENCE tenant_requisites_id_seq TO {$role};
                    END IF;
                END
                \$\$
            SQL);
        }
    }

    public function down(): void
    {
        DB::connection('pgsql_supplier')->statement('DROP TABLE IF EXISTS tenant_requisites CASCADE');
    }
};
```

- [ ] **Step 2: Применить миграцию к тест-БД**

Run: `DB_DATABASE=liderra_testing php app/artisan migrate --force`
Expected: `Migrated: 2026_06_18_140000_create_tenant_requisites`.
(Если уже отмечена выполненной — `DB_DATABASE=liderra_testing php app/artisan migrate:rollback --step=1` затем повтор.)

- [ ] **Step 3: Отразить в `db/schema.sql`** — вставить блок ПОСЛЕ `tenant_custom_domains` (раздел 4 области tenant-уровневых таблиц):

```sql
-- -----------------------------------------------------------------------------
-- tenant_requisites (G1/SP2) — реквизиты клиента (1:1), дозаполняются в ЛК.
-- Лёгкие поля (тип, контакт) — гейт первого проекта; «тяжёлые» (ИНН, КПП, ОГРН,
-- р/с) nullable, заполняются на этапе оплаты.
-- -----------------------------------------------------------------------------
CREATE TABLE tenant_requisites (
    id                       BIGSERIAL PRIMARY KEY,
    tenant_id                BIGINT NOT NULL UNIQUE REFERENCES tenants(id) ON DELETE CASCADE,
    subject_type             VARCHAR(20) NOT NULL
                             CHECK (subject_type IN ('individual','sole_proprietor','legal_entity')),
    contact_name             VARCHAR(255) NOT NULL,
    contact_phone            VARCHAR(16)  NOT NULL,        -- нормализованный +7XXXXXXXXXX
    inn                      VARCHAR(12),                  -- 10 (юр) / 12 (ИП), контрольная цифра
    legal_name               VARCHAR(255),
    kpp                      VARCHAR(9),
    ogrn                     VARCHAR(15),
    legal_address            TEXT,
    bank_name                VARCHAR(255),
    bank_bik                 VARCHAR(9),
    bank_account             VARCHAR(20),
    corr_account             VARCHAR(20),
    dadata_raw               JSONB,
    dadata_synced_at         TIMESTAMPTZ,
    requisites_completed_at  TIMESTAMPTZ,                  -- выставлен при заполнении р/с
    created_at               TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at               TIMESTAMPTZ
);

ALTER TABLE tenant_requisites ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_requisites_tenant_isolation
    ON tenant_requisites
    USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint)
    WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::bigint);

GRANT SELECT, INSERT, UPDATE ON tenant_requisites TO crm_app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON tenant_requisites TO crm_supplier_worker;
GRANT USAGE, SELECT ON SEQUENCE tenant_requisites_id_seq TO crm_app_user, crm_supplier_worker;
```

Также увеличить счётчик защищённых таблиц в шапке schema.sql (раздел про «N защищённых таблиц с ENABLE ROW LEVEL SECURITY») на +1 — сверить с header (канон).

- [ ] **Step 4: Запись в `db/CHANGELOG_schema.md`** — новая запись сверху:

```markdown
## v8.43 — 18.06.2026 — G1/SP2: tenant_requisites

- Добавлена таблица `tenant_requisites` (1:1 с `tenants`, RLS `tenant_isolation` по `tenant_id`).
  Реквизиты клиента: лёгкие (subject_type, contact_name, contact_phone) — гейт первого проекта;
  «тяжёлые» (inn, legal_name, kpp, ogrn, legal_address, банковский блок) — nullable, дозаполняются
  в ЛК на этапе оплаты. GRANT: crm_app_user (S/I/U), crm_supplier_worker (S/I/U/D).
- Миграция `2026_06_18_140000_create_tenant_requisites`.
```

- [ ] **Step 5: rls-reviewer review** — после правки schema.sql вызвать агента `rls-reviewer` на таблицу `tenant_requisites` (проверка orphan-политик, tenant_id, GRANT-консистентности, записи в CHANGELOG). Зафиксировать вердикт.

- [ ] **Step 6: Commit** — отложить (Task 9).

---

## Task 4: Модель TenantRequisites + связь Tenant

**Files:**
- Create: `app/app/Models/TenantRequisites.php`
- Modify: `app/app/Models/Tenant.php`

- [ ] **Step 1: Модель**

```php
<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

/**
 * Реквизиты тенанта (G1/SP2). 1:1 с tenants. RLS по tenant_id.
 *
 * @mixin IdeHelperTenantRequisites
 */
class TenantRequisites extends Model
{
    protected $table = 'tenant_requisites';

    protected $fillable = [
        'tenant_id', 'subject_type', 'contact_name', 'contact_phone',
        'inn', 'legal_name', 'kpp', 'ogrn', 'legal_address',
        'bank_name', 'bank_bik', 'bank_account', 'corr_account',
        'dadata_raw', 'dadata_synced_at', 'requisites_completed_at',
    ];

    protected function casts(): array
    {
        return [
            'dadata_raw' => 'array',
            'dadata_synced_at' => 'datetime',
            'requisites_completed_at' => 'datetime',
            'created_at' => 'datetime',
            'updated_at' => 'datetime',
        ];
    }

    /** @return BelongsTo<Tenant, $this> */
    public function tenant(): BelongsTo
    {
        return $this->belongsTo(Tenant::class);
    }
}
```

- [ ] **Step 2: Связь в Tenant** — добавить метод после `projects()` в `app/app/Models/Tenant.php` (использует `use Illuminate\Database\Eloquent\Relations\HasOne;` — добавить импорт):

```php
    /** @return \Illuminate\Database\Eloquent\Relations\HasOne<TenantRequisites, $this> */
    public function requisites(): \Illuminate\Database\Eloquent\Relations\HasOne
    {
        return $this->hasOne(TenantRequisites::class);
    }
```

- [ ] **Step 3: Commit** — отложить (Task 9). (Модель проверяется тестами Task 6-7.)

---

## Task 5: PartyLookup — интерфейс + DTO + Null + DaData-клиент + config + биндинг

**Files:**
- Create: `PartyLookup.php`, `Dto/PartyLookupResult.php`, `NullPartyLookup.php`, `DaDataPartyClient.php`
- Modify: `app/config/services.php`, `app/app/Providers/AppServiceProvider.php`
- Test: `app/tests/Feature/Services/DaData/DaDataPartyClientTest.php`

- [ ] **Step 1: Написать падающий тест клиента**

```php
<?php

declare(strict_types=1);

use App\Services\DaData\DaDataPartyClient;
use Illuminate\Http\Client\Factory as HttpFactory;

it('parses a party suggestion into a result', function () {
    $http = new HttpFactory;
    $http->fake([
        'suggestions.dadata.ru/*' => $http->response([
            'suggestions' => [[
                'value' => 'ООО "РОМАШКА"',
                'data' => [
                    'kpp' => '770101001',
                    'ogrn' => '1027700132195',
                    'type' => 'LEGAL',
                    'address' => ['value' => 'г Москва, ул Ленина, д 1'],
                ],
            ]],
        ]),
    ]);
    config()->set('services.dadata.api_key', 'test-key');

    $res = (new DaDataPartyClient($http))->findByInn('7707083893');

    expect($res)->not->toBeNull();
    expect($res->legalName)->toBe('ООО "РОМАШКА"');
    expect($res->kpp)->toBe('770101001');
    expect($res->type)->toBe('LEGAL');
});

it('returns null on empty suggestions', function () {
    $http = new HttpFactory;
    $http->fake(['suggestions.dadata.ru/*' => $http->response(['suggestions' => []])]);
    config()->set('services.dadata.api_key', 'test-key');

    expect((new DaDataPartyClient($http))->findByInn('0000000000'))->toBeNull();
});

it('returns null on HTTP error (soft)', function () {
    $http = new HttpFactory;
    $http->fake(['suggestions.dadata.ru/*' => $http->response('boom', 500)]);
    config()->set('services.dadata.api_key', 'test-key');

    expect((new DaDataPartyClient($http))->findByInn('7707083893'))->toBeNull();
});

it('returns null when api key is empty', function () {
    $http = new HttpFactory;
    config()->set('services.dadata.api_key', '');

    expect((new DaDataPartyClient($http))->findByInn('7707083893'))->toBeNull();
});
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `composer --working-dir=app test -- tests/Feature/Services/DaData/DaDataPartyClientTest.php`
Expected: FAIL — classes not found.

- [ ] **Step 3: DTO**

```php
<?php

declare(strict_types=1);

namespace App\Services\DaData\Dto;

final class PartyLookupResult
{
    /** @param  array<string,mixed>  $raw */
    public function __construct(
        public readonly string $legalName,
        public readonly ?string $kpp,
        public readonly ?string $ogrn,
        public readonly ?string $address,
        public readonly string $type,   // 'LEGAL' | 'INDIVIDUAL' | ''
        public readonly array $raw,
    ) {}
}
```

- [ ] **Step 4: Интерфейс**

```php
<?php

declare(strict_types=1);

namespace App\Services\DaData;

use App\Services\DaData\Dto\PartyLookupResult;

interface PartyLookup
{
    public function findByInn(string $inn): ?PartyLookupResult;
}
```

- [ ] **Step 5: NullPartyLookup**

```php
<?php

declare(strict_types=1);

namespace App\Services\DaData;

use App\Services\DaData\Dto\PartyLookupResult;

final class NullPartyLookup implements PartyLookup
{
    public function findByInn(string $inn): ?PartyLookupResult
    {
        return null; // dev/тесты по умолчанию — «не найдено»
    }
}
```

- [ ] **Step 6: DaDataPartyClient**

```php
<?php

declare(strict_types=1);

namespace App\Services\DaData;

use App\Services\DaData\Dto\PartyLookupResult;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Factory as HttpFactory;

/**
 * Suggestions findById/party — подтяжка организации по ИНН (бесплатный эндпоинт DaData).
 * POST https://suggestions.dadata.ru/suggestions/api/4_1/rs/findById/party
 *   Authorization: Token <api_key> ; body {"query":"<inn>"}
 *
 * Все ошибки (нет ключа / сеть / 4xx / 5xx / пустой ответ) → null (мягко, не бросаем).
 */
final class DaDataPartyClient implements PartyLookup
{
    private const URL = 'https://suggestions.dadata.ru/suggestions/api/4_1/rs/findById/party';

    public function __construct(private readonly HttpFactory $http) {}

    public function findByInn(string $inn): ?PartyLookupResult
    {
        $cfg = (array) config('services.dadata');
        $apiKey = (string) ($cfg['api_key'] ?? '');
        if ($apiKey === '') {
            return null;
        }
        $timeoutSec = max(1, (int) round(((int) ($cfg['timeout_ms'] ?? 2000)) / 1000));

        try {
            $response = $this->http
                ->asJson()
                ->acceptJson()
                ->timeout($timeoutSec)
                ->withHeaders(['Authorization' => 'Token '.$apiKey])
                ->post(self::URL, ['query' => $inn]);
        } catch (ConnectionException) {
            return null;
        }

        if (! $response->successful()) {
            return null;
        }

        return $this->parse($response->json());
    }

    /** @param  mixed  $body */
    private function parse($body): ?PartyLookupResult
    {
        $sug = (is_array($body) && isset($body['suggestions'][0]) && is_array($body['suggestions'][0]))
            ? $body['suggestions'][0]
            : null;
        if ($sug === null) {
            return null;
        }

        $data = is_array($sug['data'] ?? null) ? $sug['data'] : [];
        $name = (string) ($sug['value'] ?? '');
        if ($name === '') {
            return null;
        }

        return new PartyLookupResult(
            legalName: $name,
            kpp: isset($data['kpp']) ? (string) $data['kpp'] : null,
            ogrn: isset($data['ogrn']) ? (string) $data['ogrn'] : null,
            address: isset($data['address']['value']) ? (string) $data['address']['value'] : null,
            type: isset($data['type']) ? (string) $data['type'] : '',
            raw: $sug,
        );
    }
}
```

- [ ] **Step 7: config** — в `app/config/services.php`, в массив `'dadata' => [...]` добавить строку:

```php
        'party_enabled' => filter_var(env('DADATA_PARTY_ENABLED', false), FILTER_VALIDATE_BOOL),
```

- [ ] **Step 8: Биндинг** — в `app/app/Providers/AppServiceProvider.php` в методе `register()`:

```php
        $this->app->bind(\App\Services\DaData\PartyLookup::class, function ($app) {
            return config('services.dadata.party_enabled')
                ? $app->make(\App\Services\DaData\DaDataPartyClient::class)
                : $app->make(\App\Services\DaData\NullPartyLookup::class);
        });
```

- [ ] **Step 9: Запустить — убедиться, что проходит**

Run: `composer --working-dir=app test -- tests/Feature/Services/DaData/DaDataPartyClientTest.php`
Expected: PASS (4 кейса).

- [ ] **Step 10: Commit** — отложить (Task 9).

---

## Task 6: RequisitesService (upsert + isLightComplete)

**Files:**
- Create: `app/app/Services/Requisites/RequisitesService.php`
- Test: `app/tests/Feature/Requisites/RequisitesTest.php` (часть про сервис; HTTP-часть — Task 7)

> Тест-БД должна иметь миграцию из Task 3 (применена в Task 3 Step 2).

- [ ] **Step 1: Написать падающий тест сервиса**

```php
<?php

declare(strict_types=1);

use App\Models\Tenant;
use App\Services\Requisites\RequisitesService;

it('upsert normalizes phone and is idempotent on tenant', function () {
    $tenant = Tenant::factory()->create();
    $svc = new RequisitesService;

    $r1 = $svc->upsert($tenant, [
        'subject_type' => 'individual',
        'contact_name' => 'Иван Иванов',
        'contact_phone' => '8 (915) 123-45-67',
    ]);
    expect($r1->contact_phone)->toBe('+79151234567');

    $r2 = $svc->upsert($tenant, [
        'subject_type' => 'individual',
        'contact_name' => 'Иван Петров',
        'contact_phone' => '9151234567',
    ]);
    expect($r2->id)->toBe($r1->id);                 // одна строка на тенанта
    expect($r2->contact_name)->toBe('Иван Петров');
});

it('sets requisites_completed_at only when bank_account present', function () {
    $tenant = Tenant::factory()->create();
    $svc = new RequisitesService;

    $r = $svc->upsert($tenant, [
        'subject_type' => 'legal_entity', 'contact_name' => 'A', 'contact_phone' => '9151234567',
        'inn' => '7707083893',
    ]);
    expect($r->requisites_completed_at)->toBeNull();

    $r = $svc->upsert($tenant, [
        'subject_type' => 'legal_entity', 'contact_name' => 'A', 'contact_phone' => '9151234567',
        'inn' => '7707083893', 'bank_account' => '40702810900000000001',
    ]);
    expect($r->requisites_completed_at)->not->toBeNull();
});

it('isLightComplete requires inn for legal entity but not individual', function () {
    $svc = new RequisitesService;

    $t1 = Tenant::factory()->create();
    $svc->upsert($t1, ['subject_type' => 'individual', 'contact_name' => 'A', 'contact_phone' => '9151234567']);
    expect($svc->isLightComplete($t1->fresh()))->toBeTrue();

    $t2 = Tenant::factory()->create();
    $svc->upsert($t2, ['subject_type' => 'legal_entity', 'contact_name' => 'A', 'contact_phone' => '9151234567']);
    expect($svc->isLightComplete($t2->fresh()))->toBeFalse(); // нет ИНН

    $svc->upsert($t2, ['subject_type' => 'legal_entity', 'contact_name' => 'A', 'contact_phone' => '9151234567', 'inn' => '7707083893']);
    expect($svc->isLightComplete($t2->fresh()))->toBeTrue();

    $t3 = Tenant::factory()->create();
    expect($svc->isLightComplete($t3))->toBeFalse(); // вообще нет записи
});
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `composer --working-dir=app test -- tests/Feature/Requisites/RequisitesTest.php`
Expected: FAIL — `App\Services\Requisites\RequisitesService` not found.

- [ ] **Step 3: Реализовать сервис**

```php
<?php

declare(strict_types=1);

namespace App\Services\Requisites;

use App\Models\Tenant;
use App\Models\TenantRequisites;
use App\Support\PhoneNormalizer;

final class RequisitesService
{
    /**
     * @param  array<string,mixed>  $data  валидированный payload (телефон ещё в сыром виде)
     */
    public function upsert(Tenant $tenant, array $data): TenantRequisites
    {
        if (isset($data['contact_phone'])) {
            $data['contact_phone'] = PhoneNormalizer::normalize((string) $data['contact_phone']);
        }

        $req = TenantRequisites::firstOrNew(['tenant_id' => $tenant->id]);
        $req->fill($data);
        $req->tenant_id = $tenant->id;
        $req->requisites_completed_at = filled($req->bank_account) ? now() : null;
        $req->save();

        return $req;
    }

    public function isLightComplete(Tenant $tenant): bool
    {
        $r = TenantRequisites::where('tenant_id', $tenant->id)->first();
        if ($r === null) {
            return false;
        }
        if (blank($r->subject_type) || blank($r->contact_name) || blank($r->contact_phone)) {
            return false;
        }
        if (in_array($r->subject_type, ['legal_entity', 'sole_proprietor'], true) && blank($r->inn)) {
            return false;
        }

        return true;
    }
}
```

- [ ] **Step 4: Запустить — убедиться, что проходит**

Run: `composer --working-dir=app test -- tests/Feature/Requisites/RequisitesTest.php`
Expected: PASS (3 теста сервиса).

- [ ] **Step 5: Commit** — отложить (Task 9).

---

## Task 7: Requests + Resource + Controller + routes (HTTP)

**Files:**
- Create: `UpdateRequisitesRequest.php`, `LookupInnRequest.php`, `RequisitesResource.php`, `TenantRequisitesController.php`
- Modify: `app/routes/web.php`
- Test: дополнить `RequisitesTest.php` (HTTP) + `TenantRequisitesLookupTest.php`

- [ ] **Step 1: Дописать HTTP-тесты в `RequisitesTest.php`**

```php
use App\Models\User;
use Laravel\Sanctum\Sanctum;

function actingTenantUser(): User
{
    $tenant = Tenant::factory()->create();
    $user = User::factory()->create(['tenant_id' => $tenant->id]);
    Sanctum::actingAs($user);

    return $user;
}

it('GET requisites returns null when none', function () {
    actingTenantUser();
    $this->getJson('/api/tenant/requisites')->assertOk()->assertJson(['data' => null]);
});

it('PUT individual requisites succeeds and normalizes phone', function () {
    actingTenantUser();
    $this->putJson('/api/tenant/requisites', [
        'subject_type' => 'individual',
        'contact_name' => 'Иван',
        'contact_phone' => '8 915 123 45 67',
    ])->assertOk()->assertJsonPath('data.contact_phone', '+79151234567');
});

it('PUT legal entity without inn is rejected', function () {
    actingTenantUser();
    $this->putJson('/api/tenant/requisites', [
        'subject_type' => 'legal_entity',
        'contact_name' => 'A',
        'contact_phone' => '9151234567',
    ])->assertStatus(422)->assertJsonValidationErrors('inn');
});

it('PUT legal entity with bad inn checksum is rejected', function () {
    actingTenantUser();
    $this->putJson('/api/tenant/requisites', [
        'subject_type' => 'legal_entity',
        'contact_name' => 'A',
        'contact_phone' => '9151234567',
        'inn' => '7707083890',
    ])->assertStatus(422)->assertJsonValidationErrors('inn');
});

it('PUT bad phone is rejected', function () {
    actingTenantUser();
    $this->putJson('/api/tenant/requisites', [
        'subject_type' => 'individual',
        'contact_name' => 'A',
        'contact_phone' => '123',
    ])->assertStatus(422)->assertJsonValidationErrors('contact_phone');
});
```

- [ ] **Step 2: Запустить — падает (нет роутов/контроллера)**

Run: `composer --working-dir=app test -- tests/Feature/Requisites/RequisitesTest.php`
Expected: FAIL — 404/route not defined.

- [ ] **Step 3: UpdateRequisitesRequest**

```php
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use App\Support\InnValidator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class UpdateRequisitesRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user() !== null;
    }

    /** @return array<string, mixed> */
    public function rules(): array
    {
        $subjectType = (string) $this->input('subject_type');

        return [
            'subject_type' => ['required', Rule::in(['individual', 'sole_proprietor', 'legal_entity'])],
            'contact_name' => ['required', 'string', 'max:255'],
            'contact_phone' => ['required', 'string', function ($attr, $value, $fail) {
                if (\App\Support\PhoneNormalizer::normalize((string) $value) === null) {
                    $fail('Некорректный телефон.');
                }
            }],
            'inn' => [
                Rule::requiredIf(in_array($subjectType, ['legal_entity', 'sole_proprietor'], true)),
                'nullable', 'string',
                function ($attr, $value, $fail) use ($subjectType) {
                    if (in_array($subjectType, ['legal_entity', 'sole_proprietor'], true)
                        && is_string($value) && $value !== ''
                        && ! InnValidator::isValid($value, $subjectType)) {
                        $fail('Некорректный ИНН (контрольная цифра).');
                    }
                },
            ],
            'legal_name' => ['nullable', 'string', 'max:255'],
            'kpp' => ['nullable', 'string', 'regex:/^\d{9}$/'],
            'ogrn' => ['nullable', 'string', 'regex:/^(\d{13}|\d{15})$/'],
            'legal_address' => ['nullable', 'string'],
            'bank_name' => ['nullable', 'string', 'max:255'],
            'bank_bik' => ['nullable', 'string', 'regex:/^\d{9}$/'],
            'bank_account' => ['nullable', 'string', 'regex:/^\d{20}$/'],
            'corr_account' => ['nullable', 'string', 'regex:/^\d{20}$/'],
        ];
    }
}
```

- [ ] **Step 4: LookupInnRequest**

```php
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class LookupInnRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user() !== null;
    }

    /** @return array<string, mixed> */
    public function rules(): array
    {
        return [
            'inn' => ['required', 'string', 'regex:/^(\d{10}|\d{12})$/'],
        ];
    }
}
```

- [ ] **Step 5: RequisitesResource**

```php
<?php

declare(strict_types=1);

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

/** @mixin \App\Models\TenantRequisites */
class RequisitesResource extends JsonResource
{
    /** @return array<string, mixed> */
    public function toArray(Request $request): array
    {
        return [
            'subject_type' => $this->subject_type,
            'contact_name' => $this->contact_name,
            'contact_phone' => $this->contact_phone,
            'inn' => $this->inn,
            'legal_name' => $this->legal_name,
            'kpp' => $this->kpp,
            'ogrn' => $this->ogrn,
            'legal_address' => $this->legal_address,
            'bank_name' => $this->bank_name,
            'bank_bik' => $this->bank_bik,
            'bank_account' => $this->bank_account,
            'corr_account' => $this->corr_account,
            'requisites_completed_at' => $this->requisites_completed_at,
        ];
    }
}
```

- [ ] **Step 6: Controller**

```php
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Requests\LookupInnRequest;
use App\Http\Requests\UpdateRequisitesRequest;
use App\Http\Resources\RequisitesResource;
use App\Models\TenantRequisites;
use App\Services\DaData\PartyLookup;
use App\Services\Requisites\RequisitesService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class TenantRequisitesController extends Controller
{
    public function __construct(
        private readonly RequisitesService $service,
        private readonly PartyLookup $party,
    ) {}

    /** GET /api/tenant/requisites */
    public function show(Request $request): JsonResponse
    {
        $req = TenantRequisites::where('tenant_id', $request->user()->tenant_id)->first();

        return response()->json(['data' => $req ? new RequisitesResource($req) : null]);
    }

    /** PUT /api/tenant/requisites */
    public function update(UpdateRequisitesRequest $request): JsonResponse
    {
        $req = $this->service->upsert($request->user()->tenant, $request->validated());

        return response()->json(['data' => new RequisitesResource($req)]);
    }

    /** POST /api/tenant/requisites/lookup-inn — мягкая подтяжка, ничего не сохраняет */
    public function lookupInn(LookupInnRequest $request): JsonResponse
    {
        $res = $this->party->findByInn($request->validated()['inn']);
        if ($res === null) {
            return response()->json(['found' => false]);
        }

        return response()->json([
            'found' => true,
            'legal_name' => $res->legalName,
            'kpp' => $res->kpp,
            'ogrn' => $res->ogrn,
            'legal_address' => $res->address,
            'subject_type_hint' => $res->type === 'INDIVIDUAL' ? 'sole_proprietor' : 'legal_entity',
        ]);
    }
}
```

- [ ] **Step 7: Роуты** — в `app/routes/web.php` (рядом с другими `['auth:sanctum','tenant']`-группами):

```php
Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/tenant/requisites')->group(function () {
    Route::get('/', 'App\Http\Controllers\Api\TenantRequisitesController@show');
    Route::put('/', 'App\Http\Controllers\Api\TenantRequisitesController@update');
    Route::post('/lookup-inn', 'App\Http\Controllers\Api\TenantRequisitesController@lookupInn')
        ->middleware('throttle:30,1');
});
```

- [ ] **Step 8: Запустить — убедиться, что проходит**

Run: `composer --working-dir=app test -- tests/Feature/Requisites/RequisitesTest.php`
Expected: PASS (сервис + HTTP).

- [ ] **Step 9: lookup-тест**

```php
<?php

declare(strict_types=1);

use App\Models\Tenant;
use App\Models\User;
use App\Services\DaData\Dto\PartyLookupResult;
use App\Services\DaData\PartyLookup;
use Laravel\Sanctum\Sanctum;

it('lookup-inn returns found:false when party lookup is null (default Null driver)', function () {
    $tenant = Tenant::factory()->create();
    Sanctum::actingAs(User::factory()->create(['tenant_id' => $tenant->id]));

    $this->postJson('/api/tenant/requisites/lookup-inn', ['inn' => '7707083893'])
        ->assertOk()
        ->assertJson(['found' => false]);
});

it('lookup-inn returns found:true with a fake lookup', function () {
    $tenant = Tenant::factory()->create();
    Sanctum::actingAs(User::factory()->create(['tenant_id' => $tenant->id]));

    $this->app->bind(PartyLookup::class, fn () => new class implements PartyLookup {
        public function findByInn(string $inn): ?PartyLookupResult
        {
            return new PartyLookupResult('ООО Тест', '770101001', '1027700132195', 'Москва', 'LEGAL', []);
        }
    });

    $this->postJson('/api/tenant/requisites/lookup-inn', ['inn' => '7707083893'])
        ->assertOk()
        ->assertJson([
            'found' => true,
            'legal_name' => 'ООО Тест',
            'subject_type_hint' => 'legal_entity',
        ]);
});
```

Run: `composer --working-dir=app test -- tests/Feature/Requisites/TenantRequisitesLookupTest.php`
Expected: PASS.

- [ ] **Step 10: Commit** — отложить (Task 9).

---

## Task 8: Гейт первого проекта

**Files:**
- Modify: `app/app/Http/Controllers/Api/ProjectController.php`
- Test: `app/tests/Feature/Requisites/ProjectGateTest.php`

- [ ] **Step 1: Написать падающий тест гейта**

```php
<?php

declare(strict_types=1);

use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Requisites\RequisitesService;
use Laravel\Sanctum\Sanctum;

function gateUser(): User
{
    $tenant = Tenant::factory()->create();
    $user = User::factory()->create(['tenant_id' => $tenant->id]);
    Sanctum::actingAs($user);

    return $user;
}

function validProjectPayload(): array
{
    return [
        'name' => 'Тестовый проект',
        'signal_type' => 'site',
        'signal_identifier' => 'example.com',
        'daily_limit_target' => 5,
        'regions' => [],
        'delivery_days_mask' => 127,
    ];
}

it('blocks first project when requisites missing', function () {
    gateUser();
    $this->postJson('/api/projects', validProjectPayload())
        ->assertStatus(422)
        ->assertJson(['error' => 'requisites_required']);
});

it('allows first project once light requisites filled', function () {
    $user = gateUser();
    (new RequisitesService)->upsert($user->tenant, [
        'subject_type' => 'individual', 'contact_name' => 'A', 'contact_phone' => '9151234567',
    ]);
    $this->postJson('/api/projects', validProjectPayload())->assertCreated();
});

it('does not gate the second project even if requisites later cleared logically', function () {
    $user = gateUser();
    (new RequisitesService)->upsert($user->tenant, [
        'subject_type' => 'individual', 'contact_name' => 'A', 'contact_phone' => '9151234567',
    ]);
    $this->postJson('/api/projects', validProjectPayload())->assertCreated();

    // второй проект — count>0, гейт не срабатывает
    $payload = validProjectPayload();
    $payload['signal_identifier'] = 'example2.com';
    $this->postJson('/api/projects', $payload)->assertCreated();
});
```

> NB: поля `validProjectPayload()` сверить с `StoreProjectRequest` перед прогоном — привести к актуальным required-полям (разведать `app/app/Http/Requests/StoreProjectRequest.php` в разговорном режиме до реализации Task 8).

- [ ] **Step 2: Запустить — падает (первый проект создаётся без гейта)**

Run: `composer --working-dir=app test -- tests/Feature/Requisites/ProjectGateTest.php`
Expected: FAIL — первый тест получает 201 вместо 422.

- [ ] **Step 3: Внедрить гейт** — в `ProjectController`:

(а) в конструктор добавить зависимость:

```php
    public function __construct(
        private readonly ProjectService $projects,
        private readonly \App\Services\Requisites\RequisitesService $requisites,
    ) {}
```

(б) в начало `store()`, ПЕРЕД расчётом `$existingLimit`/preflight:

```php
        // G1/SP2: гейт первого проекта — нельзя создать первый проект без минимальных реквизитов.
        if (Project::where('tenant_id', $tenant->id)->count() === 0
            && ! $this->requisites->isLightComplete($tenant)) {
            return response()->json(['error' => 'requisites_required'], 422);
        }
```

(тут `$tenant = $request->user()->tenant;` уже определён выше в `store()` — гейт ставить после этой строки).

- [ ] **Step 4: Запустить — убедиться, что проходит**

Run: `composer --working-dir=app test -- tests/Feature/Requisites/ProjectGateTest.php`
Expected: PASS.

- [ ] **Step 5: Прогнать ВСЕ feature-тесты проектов (регресс гейта)**

Run: `composer --working-dir=app test -- tests/Feature/Api/ProjectControllerTest.php`
(точное имя файла сверить; если существующие project-тесты создают первый проект без реквизитов — поправить их фикстуру, добавив `RequisitesService::upsert(...)` в setup, ИЛИ создав проект напрямую через фабрику до HTTP-вызова. Это ожидаемое следствие гейта — задокументировать.)
Expected: PASS / список тестов, требующих фикстуры реквизитов.

- [ ] **Step 6: Commit** — отложить (Task 9).

---

## Task 9: Финал — регрессия + pint + коммит (терминал владельца)

- [ ] **Step 1: Полный прогон новых тестов (через эскейп)**

Run: `composer --working-dir=app test -- tests/Unit/Support tests/Feature/Requisites tests/Feature/Services/DaData/DaDataPartyClientTest.php`
Expected: всё зелёное.

- [ ] **Step 2: Прогон затронутых project-тестов** (см. Task 8 Step 5) — зелёные.

- [ ] **Step 3 (владелец, терминал): Pint**

```
composer --working-dir=app pint
```

- [ ] **Step 4 (владелец, терминал): миграция dev-БД**

```
php app/artisan migrate --force
```

- [ ] **Step 5 (владелец, терминал): коммит**

```
$env:LEFTHOOK="0"
git add app/app/Support/PhoneNormalizer.php app/app/Support/InnValidator.php app/database/migrations/2026_06_18_140000_create_tenant_requisites.php app/app/Models/TenantRequisites.php app/app/Models/Tenant.php app/app/Services/DaData/PartyLookup.php app/app/Services/DaData/NullPartyLookup.php app/app/Services/DaData/DaDataPartyClient.php "app/app/Services/DaData/Dto/PartyLookupResult.php" app/app/Services/Requisites/RequisitesService.php app/app/Http/Requests/UpdateRequisitesRequest.php app/app/Http/Requests/LookupInnRequest.php app/app/Http/Resources/RequisitesResource.php app/app/Http/Controllers/Api/TenantRequisitesController.php app/app/Http/Controllers/Api/ProjectController.php app/config/services.php app/app/Providers/AppServiceProvider.php app/routes/web.php db/schema.sql db/CHANGELOG_schema.md app/tests/Unit/Support app/tests/Feature/Requisites app/tests/Feature/Services/DaData/DaDataPartyClientTest.php
git commit -m "feat: G1/SP2 реквизиты клиента + ИНН по DaData + гейт первого проекта" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
$env:LEFTHOOK=$null
```

---

## Self-review (выполнено при написании плана)

- **Покрытие спеки:** хранилище → Task 3; модель → Task 4; нормализация телефона → Task 1; ИНН-валидация → Task 2; DaData-клиент/шов → Task 5; сервис/isLightComplete/completed_at → Task 6; эндпоинты GET/PUT/lookup → Task 7; гейт → Task 8; тесты — в каждой задаче. RLS-ревью → Task 3 Step 5.
- **Плейсхолдеры:** нет «TBD/TODO»; весь код приведён. Две явные разведки-сверки помечены (поля `StoreProjectRequest` в Task 8, имя project-теста в Task 8 Step 5) — их сделать в разговорном режиме до реализации этих шагов.
- **Консистентность типов:** `PartyLookup::findByInn(): ?PartyLookupResult`; `RequisitesService::upsert()/isLightComplete()`; `PhoneNormalizer::normalize()`; `InnValidator::isValid()` — имена совпадают во всех задачах.
- **Открытый риск:** существующие project-тесты могут начать падать из-за гейта (Task 8 Step 5) — это ожидаемо, фикстуру правим там же.
