Files
portal/app/app/Models/Tenant.php
T
Дмитрий a907fea031 feat(db): Plan 4 Task 1 — schema delta v8.18 → v8.19 + models/seeder
Schema delta (4 правки в db/schema.sql):
- tenants + delivered_in_month INT NOT NULL DEFAULT 0 CHECK (>=0) — month
  counter для PricingTierResolver O(1) lookup на горячем пути.
- lead_charges + charge_source VARCHAR(8) DEFAULT 'rub' CHECK IN ('prepaid','rub')
  + ALTER ADD CONSTRAINT chk_lead_charges_prepaid_zero_price (prepaid → price=0).
- supplier_leads + recovered_from_csv_at TIMESTAMPTZ + partial index
  WHERE recovered_from_csv_at IS NOT NULL.
- Новая таблица supplier_csv_reconcile_log (SaaS-level, без RLS) + 2 индекса
  (started_at DESC, partial status WHERE status IN ('drift_alert','failed')).

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:38:38 +03:00

81 lines
2.3 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Models;
use Database\Factories\TenantFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* Тенант — клиент SaaS-портала Лидерра.
*
* Saas-уровневая модель: НЕ имеет RLS-политик (тенанты создаются и
* управляются админкой SaaS). Используется как родительский ресурс
* для всех tenant-aware моделей (User, Project, Deal и др.).
*
* Источник: db/schema.sql v8.6 §3, table `tenants`.
*
* @mixin IdeHelperTenant
*/
class Tenant extends Model
{
/** @use HasFactory<TenantFactory> */
use HasFactory, SoftDeletes;
protected $fillable = [
'subdomain',
'organization_name',
'contact_email',
'webhook_token',
'webhook_token_rotated_at',
'timezone',
'locale',
'current_tariff_id',
'balance_rub',
'balance_leads',
'is_trial',
'trial_leads_used',
'last_activity_at',
'last_webhook_at',
'desired_daily_numbers',
'delivered_in_month',
'api_key_limit',
];
protected function casts(): array
{
return [
'is_trial' => 'boolean',
'balance_rub' => 'decimal:2',
'chargeback_unrecovered_rub' => 'decimal:2',
'balance_leads' => 'integer',
'trial_leads_used' => 'integer',
'desired_daily_numbers' => 'integer',
'delivered_in_month' => 'integer',
'api_key_limit' => 'integer',
'webhook_token_rotated_at' => 'datetime',
'last_activity_at' => 'datetime',
'last_webhook_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
'deleted_at' => 'datetime',
];
}
/** @return HasMany<User, $this> */
public function users(): HasMany
{
return $this->hasMany(User::class);
}
/** @return HasMany<Project, $this> */
public function projects(): HasMany
{
return $this->hasMany(Project::class);
}
}