Files
portal/app/app/Models/Project.php
T
Дмитрий b1c3f39e38 feat(billing-v2-c): Tenant::requiredLeadsForTomorrow + cast флагов заморозки
Task 1.3 Спека C. Tenant: +frozen_by_balance_at (fillable+cast datetime) +
requiredLeadsForTomorrow() (sum daily_limit_target активных проектов). Project:
+preflight_blocked_at (fillable+cast). NB: фильтр по is_active (boolean) +
daily_limit_target — у projects нет колонок status/daily_limit (план поправлен
под факт схемы). 3 теста GREEN.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 20:39:14 +03:00

238 lines
8.9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Models;
use App\Casts\PostgresIntArray;
use Carbon\CarbonInterface;
use Database\Factories\ProjectFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Collection;
/**
* Проект (лид-канал) внутри тенанта.
*
* Tenant-aware модель с RLS: SELECT/INSERT/UPDATE/DELETE фильтруются
* политикой `tenant_isolation` по `current_setting('app.current_tenant_id')`.
*
* Расширен в Plan 1/5 Task 1+10 для supplier integration: signal_type/identifier,
* sms_senders/keyword, delivered_in_month, supplier_b{1,2,3}_project_id (FK на
* SupplierProject — sharing-model между tenant'ами).
*
* Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §2.1
* Источник: db/schema.sql v8.16 §4, table `projects`.
*
* @mixin IdeHelperProject
*/
class Project extends Model
{
/** @use HasFactory<ProjectFactory> */
use HasFactory;
protected $fillable = [
'tenant_id',
'name',
'tag',
'type',
'is_active',
'paused_at',
'daily_limit_target',
'effective_daily_limit_today',
'effective_limit_calculated_at',
'region_mask',
'region_mode',
// Plan 6 (schema v8.20): Subject-level regions array (89 codes из resources/js/constants/regions.ts).
// Источник истины с Plan 6+; region_mask/region_mode — DEPRECATED (Plan 6.5 cleanup).
'regions',
'delivery_days_mask',
'assignment_strategy',
'ttfr_target_minutes',
// Supplier integration (Plan 1/5 Task 1+10):
'signal_type',
'signal_identifier',
'sms_senders',
'sms_keyword',
'delivered_in_month',
'supplier_b1_project_id',
'supplier_b2_project_id',
'supplier_b3_project_id',
// Plan 2/5 Task 1 (schema v8.18): дневной счётчик доставленных лидов
// (сбрасывается cron'ом в 00:00 МСК, используется LeadRouter'ом).
'delivered_today',
// Billing v2 Spec C: флаг точечной блокировки проекта по преfflight (NULL = не заблокирован).
'preflight_blocked_at',
];
protected function casts(): array
{
return [
'is_active' => 'boolean',
'paused_at' => 'datetime',
'daily_limit_target' => 'integer',
'effective_daily_limit_today' => 'integer',
'region_mask' => 'integer',
// Plan 6: Subject-level regions array (89 codes). Используется кастомный
// PostgresIntArray cast — Laravel stock 'array' посылает JSON `[1,2,3]`,
// что Postgres отвергает на INT[] (ожидает literal `{1,2,3}`).
'regions' => PostgresIntArray::class,
'delivery_days_mask' => 'integer',
'ttfr_target_minutes' => 'integer',
'effective_limit_calculated_at' => 'datetime',
// Billing v2 Spec C: флаг преfflight-блокировки проекта.
'preflight_blocked_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
// Supplier integration:
'sms_senders' => 'array',
'delivered_in_month' => 'integer',
'delivered_today' => 'integer',
];
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/** @return BelongsTo<SupplierProject, $this> */
public function supplierB1(): BelongsTo
{
return $this->belongsTo(SupplierProject::class, 'supplier_b1_project_id');
}
/** @return BelongsTo<SupplierProject, $this> */
public function supplierB2(): BelongsTo
{
return $this->belongsTo(SupplierProject::class, 'supplier_b2_project_id');
}
/** @return BelongsTo<SupplierProject, $this> */
public function supplierB3(): BelongsTo
{
return $this->belongsTo(SupplierProject::class, 'supplier_b3_project_id');
}
/**
* @return BelongsToMany<SupplierProject, $this>
*/
public function supplierProjects(): BelongsToMany
{
return $this->belongsToMany(SupplierProject::class, 'project_supplier_links')
->withPivot(['platform', 'subject_code']);
}
/**
* Активные проекты, у которых сегодняшний день включён в delivery_days_mask.
*
* delivery_days_mask — битовая маска: bit 0 = понедельник, bit 6 = воскресенье
* (ISO day-of-week минус 1). Для ISO=1 (Mon) = 1<<0 = 1; для ISO=7 (Sun) = 1<<6 = 64.
*
* @param Builder<Project> $query
* @return Builder<Project>
*/
public function scopeActiveOnDay(Builder $query, int $isoDayOfWeek): Builder
{
$bit = 1 << ($isoDayOfWeek - 1);
return $query->where('is_active', true)
->whereRaw('(delivery_days_mask & ?) <> 0', [$bit]);
}
/**
* @param Builder<Project> $query
* @return Builder<Project>
*/
public function scopeForSignal(Builder $query, string $signalType, string $identifier): Builder
{
return $query->where('signal_type', $signalType)->where('signal_identifier', $identifier);
}
/**
* Все связанные SupplierProject из eager-loaded BelongsTo отношений.
*
* Используется внутри aggregateSyncStatus(), aggregateLastSyncedAt(),
* getSupplierLinks() — устраняет N+1 (каждый из трёх методов вызывал
* SupplierProject::find() независимо; теперь читает из уже загруженных
* $this->supplierB1 / supplierB2 / supplierB3).
*
* Требует eager-load: Project::with(['supplierB1', 'supplierB2', 'supplierB3']).
*
* @return Collection<int, SupplierProject>
*/
private function resolvedSupplierProjects(): Collection
{
return collect([$this->supplierB1, $this->supplierB2, $this->supplierB3])->filter()->values();
}
/**
* Агрегированный статус синхронизации по всем связанным SupplierProject.
*
* Логика: если нет ни одного — pending; если есть failed — failed;
* если есть pending — pending; иначе — ok.
*
* Читает из eager-loaded отношений (см. resolvedSupplierProjects()).
*/
public function aggregateSyncStatus(): string
{
$statuses = $this->resolvedSupplierProjects()->pluck('sync_status');
if ($statuses->isEmpty()) {
return 'pending';
}
if ($statuses->contains('failed')) {
return 'failed';
}
if ($statuses->contains('pending')) {
return 'pending';
}
return 'ok';
}
/**
* Минимальная дата последней синхронизации по всем связанным SupplierProject.
*
* Использует sortBy по timestamp вместо Collection::min() на Carbon-объектах
* (min() сравнивает строковое представление, что ненадёжно для Carbon).
*
* Читает из eager-loaded отношений (см. resolvedSupplierProjects()).
*/
public function aggregateLastSyncedAt(): ?string
{
$ts = $this->resolvedSupplierProjects()
->pluck('last_synced_at')
->filter()
->sortBy(fn (CarbonInterface $c) => $c->timestamp)
->first();
return $ts?->toIso8601String();
}
/**
* Массив ссылок на связанные SupplierProject (для show endpoint).
*
* Читает из eager-loaded отношений (см. resolvedSupplierProjects()).
*
* @return array<int, array{platform: string, supplier_project_id: int, sync_status: string|null, last_synced_at: string|null}>
*/
public function getSupplierLinks(): array
{
return collect(['b1' => $this->supplierB1, 'b2' => $this->supplierB2, 'b3' => $this->supplierB3])
->filter()
->map(fn (SupplierProject $sp, string $platform) => [
'platform' => $platform,
'supplier_project_id' => $sp->id,
'sync_status' => $sp->sync_status,
'last_synced_at' => $sp->last_synced_at?->toIso8601String(),
])
->values()
->all();
}
}