f6072b2885
Tenant::requiredLeadsForTomorrow() previously summed raw daily_limit_target of
active projects, overcharging preflight when a tenant shared a call/site signal
with other tenants. Supplier caps the group at max(max(limits), ceil(Σ/3)) and
splits it across all clients on the same signal_identifier, so a single tenant's
real share is typically much smaller than its raw limit.
group_limits = limits of all is_active projects sharing
(signal_type, agnostic signal_identifier/sms_sender+keyword)
group_order = max(max(group_limits), ceil(Σ group_limits / 3))
tenant_share = ceil(group_order × (project_limit / Σ group_limits))
Legacy webhook projects (signal_type=null — no supplier sharing) still count
their full limit (regression-protected by existing 'sums daily_limit_target' test).
Empty groupLimits edge → conservative full-limit fallback (cross-conn race).
3 Pest tests: single project (legacy passthrough), 3-tenant share discriminator
(10→4), legacy webhook regression. Stage 4 §4.4.3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
163 lines
5.8 KiB
PHP
163 lines
5.8 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\BelongsTo;
|
||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||
use Illuminate\Support\Facades\DB;
|
||
|
||
/**
|
||
* Тенант — клиент 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',
|
||
'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',
|
||
'limits',
|
||
'frozen_by_balance_at',
|
||
];
|
||
|
||
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',
|
||
// JSONB: {"max_users":5,"max_projects":10,"api_rps":60}
|
||
'limits' => 'array',
|
||
'last_activity_at' => 'datetime',
|
||
'last_webhook_at' => 'datetime',
|
||
// Billing v2 Spec C: флаг заморозки по балансу (NULL = не заморожен).
|
||
'frozen_by_balance_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);
|
||
}
|
||
|
||
/**
|
||
* Сумма daily_limit_target активных проектов — «сколько лидов клиент хочет в день».
|
||
* Используется преfflight'ом (Billing v2 Spec C §3.3) как requiredLeads.
|
||
*
|
||
* NB: фильтр по `is_active` (boolean), не `status` — у projects нет колонки status.
|
||
*/
|
||
public function requiredLeadsForTomorrow(): int
|
||
{
|
||
// R-19 (Stage 4 §4.4.3): share-aware preflight. For each active project
|
||
// count the tenant's PROPORTIONAL share of the supplier group order (not
|
||
// the raw daily_limit_target), since the supplier caps the group at
|
||
// max(max(limits), ceil(Σ/3)) and splits it across all clients sharing
|
||
// the same signal_identifier. Legacy projects (signal_type=null —
|
||
// webhook-only, no supplier sharing) still count their full limit.
|
||
$projects = $this->projects()->where('is_active', true)->get();
|
||
if ($projects->isEmpty()) {
|
||
return 0;
|
||
}
|
||
|
||
$total = 0;
|
||
foreach ($projects as $p) {
|
||
// Webhook-only legacy projects don't participate in supplier sharing.
|
||
if (! in_array($p->signal_type, ['site', 'call', 'sms'], true)) {
|
||
$total += (int) $p->daily_limit_target;
|
||
|
||
continue;
|
||
}
|
||
|
||
$groupLimits = DB::connection('pgsql_supplier')
|
||
->table('projects')
|
||
->where('is_active', true)
|
||
->where('signal_type', $p->signal_type)
|
||
->where(function ($q) use ($p): void {
|
||
if (in_array($p->signal_type, ['site', 'call'], true)) {
|
||
$q->where('signal_identifier', $p->signal_identifier);
|
||
} else {
|
||
// sms: agnostic group is (first sender, keyword-or-NULL).
|
||
$firstSender = (string) ($p->sms_senders[0] ?? '');
|
||
$q->whereJsonContains('sms_senders', $firstSender);
|
||
if ($p->sms_keyword !== null && $p->sms_keyword !== '') {
|
||
$q->where('sms_keyword', $p->sms_keyword);
|
||
} else {
|
||
$q->whereNull('sms_keyword');
|
||
}
|
||
}
|
||
})
|
||
->pluck('daily_limit_target')
|
||
->all();
|
||
|
||
if ($groupLimits === []) {
|
||
// Edge: project not yet visible from pgsql_supplier view (cross-conn race).
|
||
// Conservatively count full limit — avoids underestimating preflight.
|
||
$total += (int) $p->daily_limit_target;
|
||
|
||
continue;
|
||
}
|
||
|
||
$intLimits = array_map('intval', $groupLimits);
|
||
$sum = (int) array_sum($intLimits);
|
||
$max = (int) max($intLimits);
|
||
$groupOrder = max($max, (int) ceil($sum / 3));
|
||
|
||
if ($sum > 0) {
|
||
$share = (int) ceil($groupOrder * ((int) $p->daily_limit_target / $sum));
|
||
$total += $share;
|
||
}
|
||
}
|
||
|
||
return $total;
|
||
}
|
||
|
||
/** @return BelongsTo<TariffPlan, $this> */
|
||
public function tariff(): BelongsTo
|
||
{
|
||
return $this->belongsTo(TariffPlan::class, 'current_tariff_id');
|
||
}
|
||
}
|