b1c3f39e38
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>
238 lines
8.9 KiB
PHP
238 lines
8.9 KiB
PHP
<?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();
|
||
}
|
||
}
|