From 08d51eb6c8ee48333b45cd9f9e17c47472aab389 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: Thu, 18 Jun 2026 22:25:23 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20G1/SP2=20=D1=80=D0=B5=D0=BA=D0=B2=D0=B8?= =?UTF-8?q?=D0=B7=D0=B8=D1=82=D1=8B=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=20+=20=D0=98=D0=9D=D0=9D=20=D0=BF=D0=BE=20DaData=20+=20?= =?UTF-8?q?=D0=B3=D0=B5=D0=B9=D1=82=20=D0=BF=D0=B5=D1=80=D0=B2=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Controllers/Api/ProjectController.php | 13 +- .../Api/TenantRequisitesController.php | 57 + app/app/Http/Requests/LookupInnRequest.php | 23 + .../Http/Requests/UpdateRequisitesRequest.php | 53 + app/app/Http/Resources/RequisitesResource.php | 33 + app/app/Models/TenantRequisites.php | 35 + app/app/Providers/AppServiceProvider.php | 12 + app/app/Services/DaData/DaDataPartyClient.php | 76 + .../Services/DaData/Dto/PartyLookupResult.php | 18 + app/app/Services/DaData/NullPartyLookup.php | 15 + app/app/Services/DaData/PartyLookup.php | 12 + .../Services/Requisites/RequisitesService.php | 46 + app/app/Support/InnValidator.php | 54 + app/app/Support/PhoneNormalizer.php | 26 + app/config/services.php | 3 + app/database/factories/TenantFactory.php | 17 + ..._06_18_140000_create_tenant_requisites.php | 69 + app/routes/web.php | 8 + .../Feature/Audit/OperationalFullFlowTest.php | 2 +- .../Feature/Billing/ProjectPreflightTest.php | 8 +- .../Plan5/Projects/ProjectsStoreTest.php | 26 +- .../Projects/ProjectMutationsAuditTest.php | 2 +- .../Feature/Requisites/ProjectGateTest.php | 54 + .../Feature/Requisites/RequisitesHttpTest.php | 58 + .../Feature/Requisites/RequisitesTest.php | 61 + .../Requisites/TenantRequisitesLookupTest.php | 38 + .../Services/DaData/DaDataPartyClientTest.php | 54 + app/tests/Unit/Support/InnValidatorTest.php | 25 + .../Unit/Support/PhoneNormalizerTest.php | 17 + db/CHANGELOG_schema.md | 22 + db/schema.sql | 46 +- .../2026-06-18-g1-sp2-requisites-gate-plan.md | 1295 +++++++++++++++++ ...26-06-18-g1-sp2-requisites-gate-spec-v1.md | 201 +++ 33 files changed, 2456 insertions(+), 23 deletions(-) create mode 100644 app/app/Http/Controllers/Api/TenantRequisitesController.php create mode 100644 app/app/Http/Requests/LookupInnRequest.php create mode 100644 app/app/Http/Requests/UpdateRequisitesRequest.php create mode 100644 app/app/Http/Resources/RequisitesResource.php create mode 100644 app/app/Models/TenantRequisites.php create mode 100644 app/app/Services/DaData/DaDataPartyClient.php create mode 100644 app/app/Services/DaData/Dto/PartyLookupResult.php create mode 100644 app/app/Services/DaData/NullPartyLookup.php create mode 100644 app/app/Services/DaData/PartyLookup.php create mode 100644 app/app/Services/Requisites/RequisitesService.php create mode 100644 app/app/Support/InnValidator.php create mode 100644 app/app/Support/PhoneNormalizer.php create mode 100644 app/database/migrations/2026_06_18_140000_create_tenant_requisites.php create mode 100644 app/tests/Feature/Requisites/ProjectGateTest.php create mode 100644 app/tests/Feature/Requisites/RequisitesHttpTest.php create mode 100644 app/tests/Feature/Requisites/RequisitesTest.php create mode 100644 app/tests/Feature/Requisites/TenantRequisitesLookupTest.php create mode 100644 app/tests/Feature/Services/DaData/DaDataPartyClientTest.php create mode 100644 app/tests/Unit/Support/InnValidatorTest.php create mode 100644 app/tests/Unit/Support/PhoneNormalizerTest.php create mode 100644 docs/superpowers/plans/2026-06-18-g1-sp2-requisites-gate-plan.md create mode 100644 docs/superpowers/specs/2026-06-18-g1-sp2-requisites-gate-spec-v1.md diff --git a/app/app/Http/Controllers/Api/ProjectController.php b/app/app/Http/Controllers/Api/ProjectController.php index 6f3a831f..3b4f9f27 100644 --- a/app/app/Http/Controllers/Api/ProjectController.php +++ b/app/app/Http/Controllers/Api/ProjectController.php @@ -15,6 +15,7 @@ use App\Models\Project; use App\Models\Tenant; use App\Services\Billing\BalancePreflightService; use App\Services\Project\ProjectService; +use App\Services\Requisites\RequisitesService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -29,7 +30,10 @@ use Illuminate\Http\Request; */ class ProjectController extends Controller { - public function __construct(private readonly ProjectService $projects) {} + public function __construct( + private readonly ProjectService $projects, + private readonly RequisitesService $requisites, + ) {} /** GET /api/projects */ public function index(Request $request): JsonResponse @@ -122,6 +126,13 @@ class ProjectController extends Controller { $validated = $request->validated(); $tenant = $request->user()->tenant; + + // G1/SP2: гейт первого проекта — нельзя создать первый проект без минимальных реквизитов. + if (Project::where('tenant_id', $tenant->id)->count() === 0 + && ! $this->requisites->isLightComplete($tenant)) { + return response()->json(['error' => 'requisites_required'], 422); + } + $forceSaveBlocked = (bool) ($validated['force_save_blocked'] ?? false); unset($validated['force_save_blocked']); diff --git a/app/app/Http/Controllers/Api/TenantRequisitesController.php b/app/app/Http/Controllers/Api/TenantRequisitesController.php new file mode 100644 index 00000000..f0f63816 --- /dev/null +++ b/app/app/Http/Controllers/Api/TenantRequisitesController.php @@ -0,0 +1,57 @@ +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', + ]); + } +} diff --git a/app/app/Http/Requests/LookupInnRequest.php b/app/app/Http/Requests/LookupInnRequest.php new file mode 100644 index 00000000..76f30b1c --- /dev/null +++ b/app/app/Http/Requests/LookupInnRequest.php @@ -0,0 +1,23 @@ +user() !== null; + } + + /** @return array */ + public function rules(): array + { + return [ + 'inn' => ['required', 'string', 'regex:/^(\d{10}|\d{12})$/'], + ]; + } +} diff --git a/app/app/Http/Requests/UpdateRequisitesRequest.php b/app/app/Http/Requests/UpdateRequisitesRequest.php new file mode 100644 index 00000000..f67e8f82 --- /dev/null +++ b/app/app/Http/Requests/UpdateRequisitesRequest.php @@ -0,0 +1,53 @@ +user() !== null; + } + + /** @return array */ + 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 (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}$/'], + ]; + } +} diff --git a/app/app/Http/Resources/RequisitesResource.php b/app/app/Http/Resources/RequisitesResource.php new file mode 100644 index 00000000..a437212e --- /dev/null +++ b/app/app/Http/Resources/RequisitesResource.php @@ -0,0 +1,33 @@ + */ + 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, + ]; + } +} diff --git a/app/app/Models/TenantRequisites.php b/app/app/Models/TenantRequisites.php new file mode 100644 index 00000000..f0ccec69 --- /dev/null +++ b/app/app/Models/TenantRequisites.php @@ -0,0 +1,35 @@ + 'array', + 'dadata_synced_at' => 'datetime', + 'requisites_completed_at' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + } +} diff --git a/app/app/Providers/AppServiceProvider.php b/app/app/Providers/AppServiceProvider.php index 9d4f420e..99ce75e3 100644 --- a/app/app/Providers/AppServiceProvider.php +++ b/app/app/Providers/AppServiceProvider.php @@ -4,6 +4,9 @@ namespace App\Providers; use App\Services\Captcha\CaptchaVerifier; use App\Services\Captcha\NullCaptchaVerifier; +use App\Services\DaData\DaDataPartyClient; +use App\Services\DaData\NullPartyLookup; +use App\Services\DaData\PartyLookup; use App\Services\Supplier\Channel\AjaxProjectChannel; use App\Services\Supplier\Channel\FailoverProjectChannel; use App\Services\Supplier\Channel\FormProjectChannel; @@ -46,6 +49,15 @@ class AppServiceProvider extends ServiceProvider CaptchaVerifier::class, NullCaptchaVerifier::class, ); + + // Шов подтяжки организации по ИНН (G1/SP2). По флагу party_enabled — + // реальный DaData suggestions; иначе Null (dev/тесты не ходят в сеть). + $this->app->bind( + PartyLookup::class, + fn ($app) => config('services.dadata.party_enabled') + ? $app->make(DaDataPartyClient::class) + : $app->make(NullPartyLookup::class), + ); } /** diff --git a/app/app/Services/DaData/DaDataPartyClient.php b/app/app/Services/DaData/DaDataPartyClient.php new file mode 100644 index 00000000..8dc5ea8b --- /dev/null +++ b/app/app/Services/DaData/DaDataPartyClient.php @@ -0,0 +1,76 @@ + ; body {"query":""} + * + * Все ошибки (нет ключа / сеть / 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, + ); + } +} diff --git a/app/app/Services/DaData/Dto/PartyLookupResult.php b/app/app/Services/DaData/Dto/PartyLookupResult.php new file mode 100644 index 00000000..1e85d012 --- /dev/null +++ b/app/app/Services/DaData/Dto/PartyLookupResult.php @@ -0,0 +1,18 @@ + $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, + ) {} +} diff --git a/app/app/Services/DaData/NullPartyLookup.php b/app/app/Services/DaData/NullPartyLookup.php new file mode 100644 index 00000000..930baa08 --- /dev/null +++ b/app/app/Services/DaData/NullPartyLookup.php @@ -0,0 +1,15 @@ + $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; + } +} diff --git a/app/app/Support/InnValidator.php b/app/app/Support/InnValidator.php new file mode 100644 index 00000000..4bb0563f --- /dev/null +++ b/app/app/Support/InnValidator.php @@ -0,0 +1,54 @@ + 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; + } +} diff --git a/app/app/Support/PhoneNormalizer.php b/app/app/Support/PhoneNormalizer.php new file mode 100644 index 00000000..2612656b --- /dev/null +++ b/app/app/Support/PhoneNormalizer.php @@ -0,0 +1,26 @@ + (int) env('DADATA_CALL_COST_KOPECKS', 60), // ≈0.60 ₽/вызов, откалибровать по тарифу 'enabled' => filter_var(env('LEAD_REGION_RESOLVER_ENABLED', false), FILTER_VALIDATE_BOOL), 'cache_ttl_days' => (int) env('PHONE_REGION_CACHE_TTL_DAYS', 30), + // G1/SP2: подтяжка организации по ИНН (suggestions findById/party). Тот же api_key + // (Token), secret не нужен. Default false → NullPartyLookup (dev/тесты не ходят в сеть). + 'party_enabled' => filter_var(env('DADATA_PARTY_ENABLED', false), FILTER_VALIDATE_BOOL), ], ]; diff --git a/app/database/factories/TenantFactory.php b/app/database/factories/TenantFactory.php index 7fe07e83..859e0bbd 100644 --- a/app/database/factories/TenantFactory.php +++ b/app/database/factories/TenantFactory.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Database\Factories; use App\Models\Tenant; +use App\Models\TenantRequisites; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Str; @@ -28,4 +29,20 @@ class TenantFactory extends Factory 'api_key_limit' => 5, ]; } + + /** + * G1/SP2: тенант с заполненными лёгкими реквизитами — проходит гейт первого + * проекта. Используется тестами, которые создают проекты у нового тенанта. + */ + public function withRequisites(): static + { + return $this->afterCreating(function (Tenant $tenant): void { + TenantRequisites::create([ + 'tenant_id' => $tenant->id, + 'subject_type' => 'individual', + 'contact_name' => 'Test Contact', + 'contact_phone' => '+79150000000', + ]); + }); + } } diff --git a/app/database/migrations/2026_06_18_140000_create_tenant_requisites.php b/app/database/migrations/2026_06_18_140000_create_tenant_requisites.php new file mode 100644 index 00000000..ac0320e9 --- /dev/null +++ b/app/database/migrations/2026_06_18_140000_create_tenant_requisites.php @@ -0,0 +1,69 @@ +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(<<statement('DROP TABLE IF EXISTS tenant_requisites CASCADE'); + } +}; diff --git a/app/routes/web.php b/app/routes/web.php index 3f8f01c1..b911321c 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -71,6 +71,14 @@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/reminders')->group(f Route::delete('/{id}', 'App\Http\Controllers\Api\ReminderController@destroy')->where('id', '[0-9]+'); }); +// Реквизиты тенанта (G1/SP2). Лёгкий гейт первого проекта + дозаполнение в ЛК. +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'); +}); + // Reports backend. Schema §13.5 report_jobs. Auth обязательный. // Этапы 1+2 (CRUD + provider/formatter) + этап 3 (retry/cancel/delete + // retention cron `reports:cleanup-expired`). diff --git a/app/tests/Feature/Audit/OperationalFullFlowTest.php b/app/tests/Feature/Audit/OperationalFullFlowTest.php index ca0fd228..9af1c457 100644 --- a/app/tests/Feature/Audit/OperationalFullFlowTest.php +++ b/app/tests/Feature/Audit/OperationalFullFlowTest.php @@ -30,7 +30,7 @@ it('full operational flow produces rows in all four audit tables', function (): Queue::fake(); // ── Shared fixtures ────────────────────────────────────────────────────── - $tenant = Tenant::factory()->create(); + $tenant = Tenant::factory()->withRequisites()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); DB::statement('SET app.current_tenant_id = '.$tenant->id); diff --git a/app/tests/Feature/Billing/ProjectPreflightTest.php b/app/tests/Feature/Billing/ProjectPreflightTest.php index 5a813455..14c5bcba 100644 --- a/app/tests/Feature/Billing/ProjectPreflightTest.php +++ b/app/tests/Feature/Billing/ProjectPreflightTest.php @@ -28,7 +28,7 @@ beforeEach(function () { it('returns 409 when new project would overload balance', function () { // 1000₽ / 50₽ = 20 лидов capacity; запрашиваем daily_limit_target=30 → дефицит 10. - $tenant = Tenant::factory()->create(['balance_rub' => '1000.00']); + $tenant = Tenant::factory()->withRequisites()->create(['balance_rub' => '1000.00']); $user = User::factory()->create(['tenant_id' => $tenant->id]); $response = $this->actingAs($user)->postJson('/api/projects', [ @@ -49,7 +49,7 @@ it('returns 409 when new project would overload balance', function () { }); it('creates blocked project when force_save_blocked=true', function () { - $tenant = Tenant::factory()->create(['balance_rub' => '1000.00']); + $tenant = Tenant::factory()->withRequisites()->create(['balance_rub' => '1000.00']); $user = User::factory()->create(['tenant_id' => $tenant->id]); $response = $this->actingAs($user)->postJson('/api/projects', [ @@ -70,7 +70,7 @@ it('creates blocked project when force_save_blocked=true', function () { it('creates normally when within balance', function () { // 2000₽ / 50₽ = 40 лидов capacity; daily_limit_target=30 — passes. - $tenant = Tenant::factory()->create(['balance_rub' => '2000.00']); + $tenant = Tenant::factory()->withRequisites()->create(['balance_rub' => '2000.00']); $user = User::factory()->create(['tenant_id' => $tenant->id]); $response = $this->actingAs($user)->postJson('/api/projects', [ @@ -89,7 +89,7 @@ it('creates normally when within balance', function () { it('returns 409 on update when increased limit overloads balance', function () { // существующий проект на 15 лидов, всё ок (capacity 20). - $tenant = Tenant::factory()->create(['balance_rub' => '1000.00']); + $tenant = Tenant::factory()->withRequisites()->create(['balance_rub' => '1000.00']); $user = User::factory()->create(['tenant_id' => $tenant->id]); $project = Project::factory()->for($tenant)->create([ 'is_active' => true, diff --git a/app/tests/Feature/Plan5/Projects/ProjectsStoreTest.php b/app/tests/Feature/Plan5/Projects/ProjectsStoreTest.php index 96165116..84b9bd1b 100644 --- a/app/tests/Feature/Plan5/Projects/ProjectsStoreTest.php +++ b/app/tests/Feature/Plan5/Projects/ProjectsStoreTest.php @@ -11,7 +11,7 @@ use Illuminate\Support\Facades\Queue; beforeEach(fn () => Queue::fake()); it('creates a site project with valid payload', function () { - $tenant = Tenant::factory()->create(); + $tenant = Tenant::factory()->withRequisites()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); $response = $this->actingAs($user)->postJson('/api/projects', [ @@ -30,7 +30,7 @@ it('creates a site project with valid payload', function () { }); it('rejects invalid site domain', function () { - $tenant = Tenant::factory()->create(); + $tenant = Tenant::factory()->withRequisites()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); $response = $this->actingAs($user)->postJson('/api/projects', [ @@ -44,7 +44,7 @@ it('rejects invalid site domain', function () { }); it('creates a call project with valid 11-digit phone', function () { - $tenant = Tenant::factory()->create(); + $tenant = Tenant::factory()->withRequisites()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); $response = $this->actingAs($user)->postJson('/api/projects', [ @@ -57,7 +57,7 @@ it('creates a call project with valid 11-digit phone', function () { }); it('rejects call signal_identifier not starting with 7', function () { - $tenant = Tenant::factory()->create(); + $tenant = Tenant::factory()->withRequisites()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); $response = $this->actingAs($user)->postJson('/api/projects', [ @@ -70,7 +70,7 @@ it('rejects call signal_identifier not starting with 7', function () { }); it('creates sms project with senders + keyword', function () { - $tenant = Tenant::factory()->create(); + $tenant = Tenant::factory()->withRequisites()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); $response = $this->actingAs($user)->postJson('/api/projects', [ @@ -87,7 +87,7 @@ it('creates sms project with senders + keyword', function () { }); it('rejects sms project without sms_senders', function () { - $tenant = Tenant::factory()->create(); + $tenant = Tenant::factory()->withRequisites()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); $response = $this->actingAs($user)->postJson('/api/projects', [ @@ -101,7 +101,7 @@ it('rejects sms project without sms_senders', function () { }); it('rejects when tenant exceeds max_projects limit', function () { - $tenant = Tenant::factory()->create(['limits' => ['max_projects' => 1]]); + $tenant = Tenant::factory()->withRequisites()->create(['limits' => ['max_projects' => 1]]); $user = User::factory()->create(['tenant_id' => $tenant->id]); Project::factory()->create(['tenant_id' => $tenant->id]); @@ -115,8 +115,8 @@ it('rejects when tenant exceeds max_projects limit', function () { }); it('forces tenant_id from auth user (not from payload)', function () { - $tenantA = Tenant::factory()->create(); - $tenantB = Tenant::factory()->create(); + $tenantA = Tenant::factory()->withRequisites()->create(); + $tenantB = Tenant::factory()->withRequisites()->create(); $userA = User::factory()->create(['tenant_id' => $tenantA->id]); $this->actingAs($userA)->postJson('/api/projects', [ @@ -131,7 +131,7 @@ it('forces tenant_id from auth user (not from payload)', function () { }); it('rejects site domain with consecutive dots', function () { - $tenant = Tenant::factory()->create(); + $tenant = Tenant::factory()->withRequisites()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); $response = $this->actingAs($user)->postJson('/api/projects', [ @@ -147,7 +147,7 @@ it('rejects site domain with consecutive dots', function () { // Plan 6 — subject-level regions[] support. it('creates project with subject-level regions array', function () { - $tenant = Tenant::factory()->create(); + $tenant = Tenant::factory()->withRequisites()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); $response = $this->actingAs($user)->postJson('/api/projects', [ @@ -166,7 +166,7 @@ it('creates project with subject-level regions array', function () { }); it('dual-writes region_mask=255 + region_mode=include for backward-compat', function () { - $tenant = Tenant::factory()->create(); + $tenant = Tenant::factory()->withRequisites()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); $response = $this->actingAs($user)->postJson('/api/projects', [ @@ -185,7 +185,7 @@ it('dual-writes region_mask=255 + region_mode=include for backward-compat', func }); it('rejects regions code out of 1..89 range with 422', function () { - $tenant = Tenant::factory()->create(); + $tenant = Tenant::factory()->withRequisites()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); $response = $this->actingAs($user)->postJson('/api/projects', [ diff --git a/app/tests/Feature/Projects/ProjectMutationsAuditTest.php b/app/tests/Feature/Projects/ProjectMutationsAuditTest.php index 8e4c2b74..3cc634ac 100644 --- a/app/tests/Feature/Projects/ProjectMutationsAuditTest.php +++ b/app/tests/Feature/Projects/ProjectMutationsAuditTest.php @@ -14,7 +14,7 @@ uses(DatabaseTransactions::class); beforeEach(function () { Queue::fake(); - $this->tenant = Tenant::factory()->create(); + $this->tenant = Tenant::factory()->withRequisites()->create(); $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]); DB::statement('SET app.current_tenant_id = '.$this->tenant->id); diff --git a/app/tests/Feature/Requisites/ProjectGateTest.php b/app/tests/Feature/Requisites/ProjectGateTest.php new file mode 100644 index 00000000..d57f1077 --- /dev/null +++ b/app/tests/Feature/Requisites/ProjectGateTest.php @@ -0,0 +1,54 @@ + */ +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 () { + $tenant = Tenant::factory()->create(); + $this->actingAs(User::factory()->create(['tenant_id' => $tenant->id])); + + $this->postJson('/api/projects', validProjectPayload()) + ->assertStatus(422) + ->assertJson(['error' => 'requisites_required']); +}); + +it('allows first project once light requisites filled', function () { + $tenant = Tenant::factory()->create(); + $this->actingAs(User::factory()->create(['tenant_id' => $tenant->id])); + (new RequisitesService)->upsert($tenant, [ + 'subject_type' => 'individual', 'contact_name' => 'A', 'contact_phone' => '9151234567', + ]); + + $this->postJson('/api/projects', validProjectPayload())->assertCreated(); +}); + +it('does not gate the second project', function () { + $tenant = Tenant::factory()->create(); + $this->actingAs(User::factory()->create(['tenant_id' => $tenant->id])); + (new RequisitesService)->upsert($tenant, [ + 'subject_type' => 'individual', 'contact_name' => 'A', 'contact_phone' => '9151234567', + ]); + + $this->postJson('/api/projects', validProjectPayload())->assertCreated(); + + $payload = validProjectPayload(); + $payload['name'] = 'Второй проект'; + $payload['signal_identifier'] = 'example2.com'; + $this->postJson('/api/projects', $payload)->assertCreated(); +}); diff --git a/app/tests/Feature/Requisites/RequisitesHttpTest.php b/app/tests/Feature/Requisites/RequisitesHttpTest.php new file mode 100644 index 00000000..78461b2d --- /dev/null +++ b/app/tests/Feature/Requisites/RequisitesHttpTest.php @@ -0,0 +1,58 @@ +create(); + $this->actingAs(User::factory()->create(['tenant_id' => $tenant->id])); + + $this->getJson('/api/tenant/requisites')->assertOk()->assertJson(['data' => null]); +}); + +it('PUT individual requisites succeeds and normalizes phone', function () { + $tenant = Tenant::factory()->create(); + $this->actingAs(User::factory()->create(['tenant_id' => $tenant->id])); + + $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 () { + $tenant = Tenant::factory()->create(); + $this->actingAs(User::factory()->create(['tenant_id' => $tenant->id])); + + $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 () { + $tenant = Tenant::factory()->create(); + $this->actingAs(User::factory()->create(['tenant_id' => $tenant->id])); + + $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 () { + $tenant = Tenant::factory()->create(); + $this->actingAs(User::factory()->create(['tenant_id' => $tenant->id])); + + $this->putJson('/api/tenant/requisites', [ + 'subject_type' => 'individual', + 'contact_name' => 'A', + 'contact_phone' => '123', + ])->assertStatus(422)->assertJsonValidationErrors('contact_phone'); +}); diff --git a/app/tests/Feature/Requisites/RequisitesTest.php b/app/tests/Feature/Requisites/RequisitesTest.php new file mode 100644 index 00000000..be06deb3 --- /dev/null +++ b/app/tests/Feature/Requisites/RequisitesTest.php @@ -0,0 +1,61 @@ +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(); // вообще нет записи +}); diff --git a/app/tests/Feature/Requisites/TenantRequisitesLookupTest.php b/app/tests/Feature/Requisites/TenantRequisitesLookupTest.php new file mode 100644 index 00000000..9e14765f --- /dev/null +++ b/app/tests/Feature/Requisites/TenantRequisitesLookupTest.php @@ -0,0 +1,38 @@ +create(); + $this->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(); + $this->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', + ]); +}); diff --git a/app/tests/Feature/Services/DaData/DaDataPartyClientTest.php b/app/tests/Feature/Services/DaData/DaDataPartyClientTest.php new file mode 100644 index 00000000..f0cd1715 --- /dev/null +++ b/app/tests/Feature/Services/DaData/DaDataPartyClientTest.php @@ -0,0 +1,54 @@ +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(); +}); diff --git a/app/tests/Unit/Support/InnValidatorTest.php b/app/tests/Unit/Support/InnValidatorTest.php new file mode 100644 index 00000000..a063a952 --- /dev/null +++ b/app/tests/Unit/Support/InnValidatorTest.php @@ -0,0 +1,25 @@ +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(); +}); diff --git a/app/tests/Unit/Support/PhoneNormalizerTest.php b/app/tests/Unit/Support/PhoneNormalizerTest.php new file mode 100644 index 00000000..014d52bd --- /dev/null +++ b/app/tests/Unit/Support/PhoneNormalizerTest.php @@ -0,0 +1,17 @@ +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], +]); diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md index 911f7234..a246a17d 100644 --- a/db/CHANGELOG_schema.md +++ b/db/CHANGELOG_schema.md @@ -4,6 +4,28 @@ **Файл схемы:** `schema.sql` (текущая версия — v8.41, консолидированная — разворачивает БД с нуля). +## v8.43 (2026-06-18) — G1/SP2 реквизиты клиента: tenant_requisites + +Backend реквизитов клиента (находка go-live G1, под-проект SP2). Новая таблица +`tenant_requisites` (1:1 с `tenants`) — лёгкие поля (тип лица, контакт, телефон) +гейтят создание первого проекта; «тяжёлые» (ИНН, наименование, КПП, ОГРН, юр.адрес, +банковский блок) — nullable, дозаполняются клиентом в личном кабинете на этапе оплаты. + +Спека: `docs/superpowers/specs/2026-06-18-g1-sp2-requisites-gate-spec-v1.md`. +План: `docs/superpowers/plans/2026-06-18-g1-sp2-requisites-gate-plan.md`. +Миграция: `app/database/migrations/2026_06_18_140000_create_tenant_requisites.php`. + +**Добавлено:** + +- **`tenant_requisites`** — таблица реквизитов, `UNIQUE(tenant_id)`, FK→tenants ON DELETE CASCADE. + Поля: `subject_type` (`individual`/`sole_proprietor`/`legal_entity`), `contact_name`, + `contact_phone` (нормализованный `+7XXXXXXXXXX`), `inn`, `legal_name`, `kpp`, `ogrn`, + `legal_address`, банковский блок (`bank_name`/`bank_bik`/`bank_account`/`corr_account`), + `dadata_raw` (JSONB), `dadata_synced_at`, `requisites_completed_at`. +- **RLS** `tenant_requisites_tenant_isolation` (USING + WITH CHECK по `app.current_tenant_id`). +- **GRANT**: `crm_app_user` (S/I/U), `crm_supplier_worker` (S/I/U/D); USAGE/SELECT на sequence. +- Счётчики в шапке схемы: защищённых таблиц 35→36, RLS-политик 35→36 (+1 с WITH CHECK). + ## v8.42 (2026-06-18) — G1/SP1 самозапись клиента: код подтверждения почты в email_verifications Backend самозаписи клиента (находка go-live G1, под-проект SP1). В таблицу diff --git a/db/schema.sql b/db/schema.sql index 6c4f4a1a..81bb9a2f 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -224,9 +224,10 @@ -- • 91 индекс (86 из v8.4 + 5 новых: idx_deals_utm_source, -- idx_deals_region_code, idx_deals_duplicate_of, idx_deals_assigned_at_open, -- idx_project_user_assignments_user). --- • 35 RLS-политик (34 из v8.4 + 1 на project_user_assignments через JOIN). --- Из них 2 политики обогащены WITH CHECK (deal_tag_pivot, saas_invoice_items). --- • 35 защищённых таблиц с ENABLE ROW LEVEL SECURITY (1:1 соответствие политикам). +-- • 36 RLS-политик (34 из v8.4 + 1 на project_user_assignments через JOIN +-- + 1 на tenant_requisites — G1/SP2). +-- Из них 3 политики обогащены WITH CHECK (deal_tag_pivot, saas_invoice_items, tenant_requisites). +-- • 36 защищённых таблиц с ENABLE ROW LEVEL SECURITY (1:1 соответствие политикам). -- • 4 роли БД (3 из v8.4 + crm_audit_writer — только INSERT на 5 audit-таблицах). -- • 12 триггеров: на 5 audit-таблицах (auth_log/activity_log/pd_processing_log/ -- saas_admin_audit_log/balance_transactions) — по 2 (BEFORE INSERT для hash @@ -721,6 +722,45 @@ CREATE TABLE tenant_custom_domains ( ); +-- ----------------------------------------------------------------------------- +-- 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; + + -- ============================================================================= -- 4. TENANT-УРОВНЕВЫЕ БАЗОВЫЕ ТАБЛИЦЫ -- ============================================================================= diff --git a/docs/superpowers/plans/2026-06-18-g1-sp2-requisites-gate-plan.md b/docs/superpowers/plans/2026-06-18-g1-sp2-requisites-gate-plan.md new file mode 100644 index 00000000..ff625560 --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-g1-sp2-requisites-gate-plan.md @@ -0,0 +1,1295 @@ +# 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: `, батч 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 +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 +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 + 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 +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(<<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 + 'array', + 'dadata_synced_at' => 'datetime', + 'requisites_completed_at' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + 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 */ + 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 +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 + $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 + ; body {"query":""} + * + * Все ошибки (нет ключа / сеть / 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 +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 + $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 +user() !== null; + } + + /** @return array */ + 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 +user() !== null; + } + + /** @return array */ + public function rules(): array + { + return [ + 'inn' => ['required', 'string', 'regex:/^(\d{10}|\d{12})$/'], + ]; + } +} +``` + +- [ ] **Step 5: RequisitesResource** + +```php + */ + 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 +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 +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 +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) " +$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) — это ожидаемо, фикстуру правим там же. diff --git a/docs/superpowers/specs/2026-06-18-g1-sp2-requisites-gate-spec-v1.md b/docs/superpowers/specs/2026-06-18-g1-sp2-requisites-gate-spec-v1.md new file mode 100644 index 00000000..a7055cc4 --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-g1-sp2-requisites-gate-spec-v1.md @@ -0,0 +1,201 @@ +# G1/SP2 — реквизиты клиента + ИНН по DaData + гейт первого проекта (backend) + +**Дата:** 18.06.2026. **Спринт:** G1 (самозапись клиента), под-спринт SP2. +**Предшественник:** SP1 (самозапись + подтверждение почты), коммит `53fb7b77`. +**Слой:** backend (Laravel 13), проверяемо Pest. Фронт (личный кабинет, форма) — SP3. + +## Цель {#goal} + +Дать клиенту-тенанту завести минимальные сведения о себе («кто пришёл» + контакт) и +зафиксировать тип лица; для ИП/юрлица — взять ИНН и **мягко** подтянуть наименование по +DaData. До заполнения этого минимума клиент **не может создать первый проект** (гейт). + +Хранилище и эндпоинты спроектированы так, чтобы позже, на этапе оплаты/пополнения баланса, +клиент **дозаполнил** полные реквизиты (КПП, ОГРН, юр.адрес, расчётный счёт) в личном +кабинете — те же эндпоинты, те же поля (изначально пустые/nullable). + +Решения владельца (18.06.2026), зафиксированные в этой спеке: + +- Перед первым проектом нужен **лёгкий минимум**: тип лица + контакт (имя + телефон). +- Для ИП/юрлица — ИНН **сразу**, наименование подтягивается по DaData. +- DaData — **мягко**: молчит/не нашла/нет ключа → пропускаем, клиент введёт сам. Внешний + сервис **не блокирует** старт. +- Полные «тяжёлые» реквизиты (р/с и т.д.) — дозаполняются позже при оплате; backend под это + готов уже сейчас (поля заведены nullable). + +## Конвенции и границы {#scope} + +**Входит в SP2:** + +1. Таблица `tenant_requisites` (1:1 с тенантом) — лёгкие + «тяжёлые» поля (последние nullable). +2. Эндпоинты `GET /api/tenant/requisites`, `PUT /api/tenant/requisites`, + `POST /api/tenant/requisites/lookup-inn`. +3. `DaDataPartyClient` (suggestions `findById/party`) за интерфейсом + Null/Fake-драйвер. +4. Нормализация телефона до `+7XXXXXXXXXX`; валидация ИНН по контрольной цифре. +5. Гейт первого проекта в `ProjectController::store()`. +6. Тесты Pest на всё перечисленное. + +**НЕ входит (явно отложено):** + +- Фронт (форма реквизитов, личный кабинет, маска ввода в UI) — SP3. +- Реальный ключ DaData party на проде, выкат — ops/позже. +- Логика этапа оплаты (счета, эквайринг, УПД) — отдельный спринт; SP2 лишь готовит хранилище. +- Полная валидация банковских реквизитов против справочника БИК — позже (сейчас только формат). + +## Хранилище: таблица `tenant_requisites` {#storage} + +Tenant-aware таблица (RLS по `tenant_id`), 1:1 с тенантом (`UNIQUE(tenant_id)`). + +| Поле | Тип | Назначение | +|---|---|---| +| `id` | BIGSERIAL PK | | +| `tenant_id` | BIGINT NOT NULL, FK→tenants, UNIQUE | владелец | +| `subject_type` | VARCHAR(20) NOT NULL CHECK in (`individual`,`sole_proprietor`,`legal_entity`) | физ/ИП/юр | +| `contact_name` | VARCHAR(255) NOT NULL | контактное лицо (лёгкий гейт) | +| `contact_phone` | VARCHAR(16) NOT NULL | нормализованный `+7XXXXXXXXXX` (лёгкий гейт) | +| `inn` | VARCHAR(12) NULL | 10 (юр) / 12 (ИП); контрольная цифра | +| `legal_name` | VARCHAR(255) NULL | наименование (DaData или руками) | +| `kpp` | VARCHAR(9) NULL | дозаполняется при оплате | +| `ogrn` | VARCHAR(15) NULL | дозаполняется (DaData отдаёт) | +| `legal_address` | TEXT NULL | дозаполняется (DaData отдаёт) | +| `bank_name` | VARCHAR(255) NULL | дозаполняется при оплате | +| `bank_bik` | VARCHAR(9) NULL | дозаполняется при оплате | +| `bank_account` | VARCHAR(20) NULL | расчётный счёт, при оплате | +| `corr_account` | VARCHAR(20) NULL | корр.счёт, при оплате | +| `dadata_raw` | JSONB NULL | сырой ответ DaData (провенанс) | +| `dadata_synced_at` | TIMESTAMPTZ NULL | когда подтянули | +| `requisites_completed_at` | TIMESTAMPTZ NULL | когда заполнен расчётный счёт (флаг готовности к оплате) | +| `created_at`/`updated_at` | TIMESTAMPTZ | | + +**RLS:** политика `tenant_id = current_setting('app.current_tenant_id')::bigint` (как у прочих +tenant-aware таблиц). GRANT на `crm_app_user`, `crm_app_admin`; `crm_supplier_worker` — +BYPASSRLS. Правка `db/schema.sql` + запись в `db/CHANGELOG_schema.md` + миграция. Обязательное +ревью `rls-reviewer`. + +## Валидация {#validation} + +### Телефон (нормализация) + +Снять все нецифры. Затем: + +- 11 цифр, начинается с `8` → заменить ведущую `8` на `7`. +- 11 цифр, начинается с `7` → как есть. +- 10 цифр → добавить `7` в начало. +- иначе → `422` (`contact_phone` invalid). + +Хранить как `+7` + 10 цифр (т.е. `+7XXXXXXXXXX`, длина 12 символов). + +### ИНН (контрольная цифра) + +- `legal_entity` → ровно **10** цифр, контроль: + веса `[2,4,10,3,5,9,4,6,8]`, `c = (Σ wᵢ·dᵢ mod 11) mod 10`, `c == d10`. +- `sole_proprietor` → ровно **12** цифр, два контроля: + - `n11`: веса `[7,2,4,10,3,5,9,4,6,8]`, `(Σ mod 11) mod 10 == d11`; + - `n12`: веса `[3,7,2,4,10,3,5,9,4,6,8]`, `(Σ mod 11) mod 10 == d12`. +- `individual` → ИНН не требуется (если прислан — не валидируется как обязательный; для SP2 + физлицу ИНН не нужен). + +Невалидный ИНН для ИП/юр → `422`. + +### `PUT` по типам + +`subject_type` обязателен (enum). `contact_name`, `contact_phone` обязательны всегда. +`inn` обязателен при `subject_type ∈ {legal_entity, sole_proprietor}` (+ контрольная цифра). +`legal_name` — **nullable** (мягкая DaData; может быть пуст на гейте, дозаполняется). +«Тяжёлые» поля nullable; при наличии — проверка формата (`kpp` 9 цифр, `bank_bik` 9 цифр, +`bank_account`/`corr_account` 20 цифр, `ogrn` 13 или 15 цифр). PUT **не** дёргает DaData. + +## Эндпоинты {#endpoints} + +Группа `Route::middleware(['auth:sanctum','tenant'])->prefix('/api/tenant/requisites')`. + +- **`GET /`** → текущие реквизиты тенанта (`RequisitesResource`) либо `data: null`, если ещё + не заведены (фронт показывает пустую форму). +- **`PUT /`** → upsert (создать или обновить) по `tenant_id` текущего пользователя. Валидация + `UpdateRequisitesRequest` (см. §validation). Телефон нормализуется. Возвращает + `RequisitesResource`. Выставляет `requisites_completed_at` = `now()`, когда заполнен + `bank_account` (расчётный счёт) — однозначный сигнал готовности платить по счёту; иначе NULL. +- **`POST /lookup-inn`** body `{inn}` → мягкая подтяжка через `DaDataPartyClient`: + - найдено → `{found:true, legal_name, kpp, ogrn, legal_address, subject_type_hint}`; + - не найдено / DaData молчит / выключено / нет ключа → `{found:false}` (HTTP 200, без ошибки). + - Эндпоинт **ничего не сохраняет** — только подсказка для автозаполнения формы (SP3). + - Троттлинг: `throttle` на эндпоинт (защита от перебора ИНН). + +## DaData party-клиент {#dadata} + +`App\Services\DaData\DaDataPartyClient` — POST на +`https://suggestions.dadata.ru/suggestions/api/4_1/rs/findById/party`, заголовок +`Authorization: Token ` (secret для suggestions **не** нужен), тело +`{"query":""}`. Парсит первую suggestion → DTO `PartyLookupResult` +(`legalName`, `kpp`, `ogrn`, `address`, `type`, `raw`). + +Интерфейс `App\Services\DaData\PartyLookup` с двумя реализациями: + +- `DaDataPartyClient` (реальный HTTP); +- `NullPartyLookup` (dev/тесты по умолчанию; всегда «не найдено»). + +Биндинг в провайдере по флагу `config('services.dadata.party_enabled')` +(env `DADATA_PARTY_ENABLED`, default `false`). Сетевые ошибки/таймаут/5xx/4xx внутри +клиента → возвращают «не найдено» (мягко, не бросают наружу). Тесты подменяют через `Fake`/ +`HttpFactory::fake()` по образцу `DaDataPhoneClientTest`. + +Config-добавка в `app/config/services.php` блок `dadata`: +`'party_enabled' => filter_var(env('DADATA_PARTY_ENABLED', false), FILTER_VALIDATE_BOOL)`. + +## Гейт первого проекта {#gate} + +В `ProjectController::store()` **до** preflight-баланса: если у тенанта **0 проектов** +(`Project::where('tenant_id',$tenant->id)->count() === 0`) **и** реквизиты не прошли +лёгкий минимум → `422` с телом `{"error":"requisites_required"}`. + +«Лёгкий минимум выполнен» (хелпер `RequisitesService::isLightComplete(Tenant): bool`): + +- запись `tenant_requisites` существует; +- `subject_type` задан; +- `contact_name` непуст; +- `contact_phone` непуст; +- если `subject_type ∈ {legal_entity, sole_proprietor}` — `inn` непуст (валидность гарантирована PUT). + +`legal_name` для гейта **не** обязателен (DaData мягкая). После создания первого проекта гейт +не срабатывает (`count > 0`). Гейт стоит только на `store` клиентского API; админские/импорт- +пути вне SP2. + +## Файлы (карта реализации) {#files} + +- `app/database/migrations/2026_06_18_*_create_tenant_requisites.php` — таблица + RLS + GRANT. +- `db/schema.sql` — `CREATE TABLE tenant_requisites` + политика; `db/CHANGELOG_schema.md` — запись (вер. v8.43). +- `app/app/Models/TenantRequisites.php` — модель (fillable, casts, `belongsTo Tenant`); `Tenant::requisites()` hasOne. +- `app/app/Services/DaData/PartyLookup.php` (интерфейс), `DaDataPartyClient.php`, `NullPartyLookup.php`, `Dto/PartyLookupResult.php`. +- `app/app/Services/Requisites/RequisitesService.php` — upsert, нормализация телефона, `isLightComplete`, `requisites_completed_at`. +- `app/app/Support/PhoneNormalizer.php` (или метод в сервисе) + `app/app/Support/InnValidator.php`. +- `app/app/Http/Controllers/Api/TenantRequisitesController.php` — `show`/`update`/`lookupInn`. +- `app/app/Http/Requests/UpdateRequisitesRequest.php`, `LookupInnRequest.php`. +- `app/app/Http/Resources/RequisitesResource.php`. +- `app/app/Http/Controllers/Api/ProjectController.php` — гейт в `store()`. +- `app/routes/web.php` — группа `/api/tenant/requisites` + `throttle` на `lookup-inn`. +- Провайдер (`AppServiceProvider` или профильный) — биндинг `PartyLookup` по флагу. + +## Тесты Pest {#tests} + +- `RequisitesTest`: GET пусто → `data:null`; PUT физлицо (имя+телефон) ok; PUT ИП без ИНН → + 422; PUT юр с битым ИНН (контр. цифра) → 422; нормализация телефона (`8...`/`+7...`/10 цифр + → `+7XXXXXXXXXX`); PUT с `bank_account` → `requisites_completed_at` выставлен, без него NULL; + формат банковских полей. +- `TenantRequisitesLookupTest`: Fake DaData возвращает организацию → `found:true` + поля; + выключено/молчит → `found:false` HTTP 200. +- `DaDataPartyClientTest`: парсинг suggestions-ответа; сетевой сбой/4xx/5xx → «не найдено». +- `InnValidatorTest`: позитив/негатив для 10 и 12 цифр (известные контрольные примеры). +- `PhoneNormalizerTest`: таблица входов→выходов + невалидные. +- `ProjectGateTest`: 0 проектов без реквизитов → 422 `requisites_required`; с лёгкими + реквизитами → 201; второй проект (count>0) без проверки реквизитов → проходит preflight как + обычно. + +## verified-context + +```verified-context-json +[ + {"id":"D-proj","kind":"EXTRACTED","ref":"app/app/Http/Controllers/Api/ProjectController.php","anchor":"public function store"}, + {"id":"D-tenants","kind":"EXTRACTED","ref":"db/schema.sql","anchor":"CREATE TABLE tenants"}, + {"id":"D-dadata","kind":"EXTRACTED","ref":"app/app/Services/DaData/DaDataPhoneClient.php","anchor":"class DaDataPhoneClient"} +] +```