2026-05-08 14:29:50 +03:00
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
|
|
namespace App\Models;
|
|
|
|
|
|
|
2026-05-15 05:39:43 +03:00
|
|
|
|
use App\Casts\PostgresIntArray;
|
2026-05-11 18:15:36 +03:00
|
|
|
|
use Carbon\CarbonInterface;
|
2026-05-08 14:29:50 +03:00
|
|
|
|
use Database\Factories\ProjectFactory;
|
2026-05-10 16:59:53 +03:00
|
|
|
|
use Illuminate\Database\Eloquent\Builder;
|
2026-05-08 14:29:50 +03:00
|
|
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
|
|
|
|
use Illuminate\Database\Eloquent\Model;
|
|
|
|
|
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
2026-05-20 11:04:24 +03:00
|
|
|
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
2026-05-11 18:08:01 +03:00
|
|
|
|
use Illuminate\Support\Collection;
|
2026-05-08 14:29:50 +03:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Проект (лид-канал) внутри тенанта.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Tenant-aware модель с RLS: SELECT/INSERT/UPDATE/DELETE фильтруются
|
|
|
|
|
|
* политикой `tenant_isolation` по `current_setting('app.current_tenant_id')`.
|
|
|
|
|
|
*
|
2026-05-10 16:59:53 +03:00
|
|
|
|
* Расширен в 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`.
|
2026-05-08 15:24:55 +03:00
|
|
|
|
*
|
|
|
|
|
|
* @mixin IdeHelperProject
|
2026-05-08 14:29:50 +03:00
|
|
|
|
*/
|
|
|
|
|
|
class Project extends Model
|
|
|
|
|
|
{
|
|
|
|
|
|
/** @use HasFactory<ProjectFactory> */
|
|
|
|
|
|
use HasFactory;
|
|
|
|
|
|
|
|
|
|
|
|
protected $fillable = [
|
|
|
|
|
|
'tenant_id',
|
|
|
|
|
|
'name',
|
|
|
|
|
|
'tag',
|
|
|
|
|
|
'type',
|
|
|
|
|
|
'is_active',
|
2026-05-26 11:17:05 +03:00
|
|
|
|
'paused_at',
|
2026-05-08 14:29:50 +03:00
|
|
|
|
'daily_limit_target',
|
|
|
|
|
|
'effective_daily_limit_today',
|
|
|
|
|
|
'effective_limit_calculated_at',
|
|
|
|
|
|
'region_mask',
|
|
|
|
|
|
'region_mode',
|
2026-05-15 05:39:43 +03:00
|
|
|
|
// 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',
|
2026-05-08 14:29:50 +03:00
|
|
|
|
'delivery_days_mask',
|
|
|
|
|
|
'assignment_strategy',
|
|
|
|
|
|
'ttfr_target_minutes',
|
2026-05-10 16:59:53 +03:00
|
|
|
|
// 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',
|
2026-05-10 18:50:28 +03:00
|
|
|
|
// Plan 2/5 Task 1 (schema v8.18): дневной счётчик доставленных лидов
|
|
|
|
|
|
// (сбрасывается cron'ом в 00:00 МСК, используется LeadRouter'ом).
|
|
|
|
|
|
'delivered_today',
|
2026-05-24 11:54:18 +03:00
|
|
|
|
// Billing v2 Spec C: флаг точечной блокировки проекта по преfflight (NULL = не заблокирован).
|
|
|
|
|
|
'preflight_blocked_at',
|
2026-05-08 14:29:50 +03:00
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
protected function casts(): array
|
|
|
|
|
|
{
|
|
|
|
|
|
return [
|
|
|
|
|
|
'is_active' => 'boolean',
|
2026-05-26 11:17:05 +03:00
|
|
|
|
'paused_at' => 'datetime',
|
2026-05-08 14:29:50 +03:00
|
|
|
|
'daily_limit_target' => 'integer',
|
|
|
|
|
|
'effective_daily_limit_today' => 'integer',
|
|
|
|
|
|
'region_mask' => 'integer',
|
2026-05-15 05:39:43 +03:00
|
|
|
|
// 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,
|
2026-05-08 14:29:50 +03:00
|
|
|
|
'delivery_days_mask' => 'integer',
|
|
|
|
|
|
'ttfr_target_minutes' => 'integer',
|
|
|
|
|
|
'effective_limit_calculated_at' => 'datetime',
|
2026-05-24 11:54:18 +03:00
|
|
|
|
// Billing v2 Spec C: флаг преfflight-блокировки проекта.
|
|
|
|
|
|
'preflight_blocked_at' => 'datetime',
|
2026-05-08 14:29:50 +03:00
|
|
|
|
'created_at' => 'datetime',
|
|
|
|
|
|
'updated_at' => 'datetime',
|
2026-05-10 16:59:53 +03:00
|
|
|
|
// Supplier integration:
|
|
|
|
|
|
'sms_senders' => 'array',
|
|
|
|
|
|
'delivered_in_month' => 'integer',
|
2026-05-10 18:50:28 +03:00
|
|
|
|
'delivered_today' => 'integer',
|
2026-05-08 14:29:50 +03:00
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** @return BelongsTo<Tenant, $this> */
|
|
|
|
|
|
public function tenant(): BelongsTo
|
|
|
|
|
|
{
|
|
|
|
|
|
return $this->belongsTo(Tenant::class);
|
|
|
|
|
|
}
|
2026-05-10 16:59:53 +03:00
|
|
|
|
|
|
|
|
|
|
/** @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');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 11:04:24 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* @return BelongsToMany<SupplierProject, $this>
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function supplierProjects(): BelongsToMany
|
|
|
|
|
|
{
|
|
|
|
|
|
return $this->belongsToMany(SupplierProject::class, 'project_supplier_links')
|
|
|
|
|
|
->withPivot(['platform', 'subject_code']);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-10 16:59:53 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* Активные проекты, у которых сегодняшний день включён в 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);
|
|
|
|
|
|
}
|
2026-05-11 17:39:46 +03:00
|
|
|
|
|
2026-05-11 18:15:36 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* Все связанные 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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-11 18:08:01 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* Агрегированный статус синхронизации по всем связанным SupplierProject.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Логика: если нет ни одного — pending; если есть failed — failed;
|
|
|
|
|
|
* если есть pending — pending; иначе — ok.
|
2026-05-11 18:15:36 +03:00
|
|
|
|
*
|
|
|
|
|
|
* Читает из eager-loaded отношений (см. resolvedSupplierProjects()).
|
2026-05-11 18:08:01 +03:00
|
|
|
|
*/
|
|
|
|
|
|
public function aggregateSyncStatus(): string
|
|
|
|
|
|
{
|
2026-05-11 18:15:36 +03:00
|
|
|
|
$statuses = $this->resolvedSupplierProjects()->pluck('sync_status');
|
2026-05-11 18:08:01 +03:00
|
|
|
|
|
|
|
|
|
|
if ($statuses->isEmpty()) {
|
|
|
|
|
|
return 'pending';
|
|
|
|
|
|
}
|
|
|
|
|
|
if ($statuses->contains('failed')) {
|
|
|
|
|
|
return 'failed';
|
|
|
|
|
|
}
|
|
|
|
|
|
if ($statuses->contains('pending')) {
|
|
|
|
|
|
return 'pending';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return 'ok';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Минимальная дата последней синхронизации по всем связанным SupplierProject.
|
2026-05-11 18:15:36 +03:00
|
|
|
|
*
|
|
|
|
|
|
* Использует sortBy по timestamp вместо Collection::min() на Carbon-объектах
|
|
|
|
|
|
* (min() сравнивает строковое представление, что ненадёжно для Carbon).
|
|
|
|
|
|
*
|
|
|
|
|
|
* Читает из eager-loaded отношений (см. resolvedSupplierProjects()).
|
2026-05-11 18:08:01 +03:00
|
|
|
|
*/
|
|
|
|
|
|
public function aggregateLastSyncedAt(): ?string
|
|
|
|
|
|
{
|
2026-05-11 18:15:36 +03:00
|
|
|
|
$ts = $this->resolvedSupplierProjects()
|
|
|
|
|
|
->pluck('last_synced_at')
|
2026-05-11 18:08:01 +03:00
|
|
|
|
->filter()
|
2026-05-11 18:15:36 +03:00
|
|
|
|
->sortBy(fn (CarbonInterface $c) => $c->timestamp)
|
|
|
|
|
|
->first();
|
2026-05-11 18:08:01 +03:00
|
|
|
|
|
|
|
|
|
|
return $ts?->toIso8601String();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Массив ссылок на связанные SupplierProject (для show endpoint).
|
|
|
|
|
|
*
|
2026-05-11 18:15:36 +03:00
|
|
|
|
* Читает из eager-loaded отношений (см. resolvedSupplierProjects()).
|
|
|
|
|
|
*
|
2026-05-11 18:08:01 +03:00
|
|
|
|
* @return array<int, array{platform: string, supplier_project_id: int, sync_status: string|null, last_synced_at: string|null}>
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function getSupplierLinks(): array
|
|
|
|
|
|
{
|
2026-05-11 18:15:36 +03:00
|
|
|
|
return collect(['b1' => $this->supplierB1, 'b2' => $this->supplierB2, 'b3' => $this->supplierB3])
|
2026-05-11 18:08:01 +03:00
|
|
|
|
->filter()
|
2026-05-11 18:15:36 +03:00
|
|
|
|
->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(),
|
|
|
|
|
|
])
|
2026-05-11 18:08:01 +03:00
|
|
|
|
->values()
|
|
|
|
|
|
->all();
|
|
|
|
|
|
}
|
2026-05-08 14:29:50 +03:00
|
|
|
|
}
|