ba7edaab7b
Производный статус тенанта (trial>suspended>overdue>active) был скопирован в 3 контроллерах портала продаж. Вынесен в App\Http\Controllers\Concerns\DerivesTenantStatus; контроллеры (Clients/Dashboard/Managers) теперь используют трейт. Поведение не изменено — sales-набор 28/28 затронутых, Larastan 0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
567 lines
24 KiB
PHP
567 lines
24 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Http\Controllers\Api\Sales;
|
||
|
||
use App\Http\Controllers\Concerns\DerivesTenantStatus;
|
||
use App\Http\Controllers\Concerns\ScopesSalesOwnership;
|
||
use App\Http\Controllers\Controller;
|
||
use App\Models\SalesClientAssignment;
|
||
use App\Models\SalesUser;
|
||
use App\Services\Sales\SalesEarningsService;
|
||
use App\Services\Sales\SalesMetricsService;
|
||
use App\Services\Sales\SalesPeriodResolver;
|
||
use Carbon\CarbonImmutable;
|
||
use Illuminate\Http\JsonResponse;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Support\Facades\DB;
|
||
|
||
/**
|
||
* Портал продаж — экран «Мои клиенты» + карточка клиента + сводка.
|
||
*
|
||
* GET /api/sales/clients — список (Task 1.3)
|
||
* GET /api/sales/clients/{tenantId} — карточка (Task 1.4)
|
||
* GET /api/sales/overview — сводка менеджера (Task 1.5)
|
||
*
|
||
* Менеджер видит только своих клиентов (через ScopesSalesOwnership);
|
||
* начальник (role=head) видит всех.
|
||
*
|
||
* Параметры периода (оба метода):
|
||
* ?period=this|prev|prev2|custom (default: this)
|
||
* ?from=YYYY-MM-DD (только для period=custom)
|
||
* ?to=YYYY-MM-DD (только для period=custom)
|
||
*
|
||
* Spec: docs/superpowers/plans/2026-06-30-sales-portal.md (Task 1.3, Task 1.4, Task 1.5)
|
||
*/
|
||
class SalesClientsController extends Controller
|
||
{
|
||
use DerivesTenantStatus;
|
||
use ScopesSalesOwnership;
|
||
|
||
/**
|
||
* Список клиентов с метриками периода.
|
||
*/
|
||
public function index(Request $request): JsonResponse
|
||
{
|
||
/** @var SalesUser $user */
|
||
$user = $request->user('sales');
|
||
|
||
// 1. Период
|
||
$period = app(SalesPeriodResolver::class)->resolve([
|
||
'kind' => $request->query('period', 'this'),
|
||
'from' => $request->query('from'),
|
||
'to' => $request->query('to'),
|
||
]);
|
||
|
||
// 2. Tenant scope
|
||
$ids = $this->ownedTenantIds($user);
|
||
|
||
// 3. Базовый запрос: tenants + LEFT JOIN tenant_requisites + LEFT JOIN assignment + tariff
|
||
$query = DB::table('tenants')
|
||
->leftJoin('tenant_requisites', 'tenant_requisites.tenant_id', '=', 'tenants.id')
|
||
->leftJoin('sales_client_assignments as sca', 'sca.tenant_id', '=', 'tenants.id')
|
||
->leftJoin('sales_tariffs as st', 'st.id', '=', 'sca.tariff_id')
|
||
->whereNull('tenants.deleted_at')
|
||
->select([
|
||
'tenants.id as tenant_id',
|
||
'tenants.organization_name',
|
||
'tenants.status',
|
||
'tenants.is_trial',
|
||
'tenants.balance_rub',
|
||
'tenants.chargeback_unrecovered_rub',
|
||
'tenants.last_activity_at',
|
||
'tenant_requisites.inn',
|
||
'tenant_requisites.subject_type',
|
||
'st.name as tariff_name',
|
||
]);
|
||
|
||
// Ограничение по владению: null = начальник (без ограничения)
|
||
if ($ids !== null) {
|
||
$query->whereIn('tenants.id', $ids === [] ? [-1] : $ids);
|
||
}
|
||
|
||
// Поиск
|
||
$search = trim((string) $request->query('search', ''));
|
||
if ($search !== '') {
|
||
$like = '%'.$search.'%';
|
||
$query->where(function ($q) use ($like): void {
|
||
$q->where('tenants.organization_name', 'ilike', $like)
|
||
->orWhere('tenant_requisites.inn', 'ilike', $like);
|
||
});
|
||
}
|
||
|
||
$rows = $query
|
||
->orderByDesc('tenants.last_activity_at')
|
||
->orderBy('tenants.id')
|
||
->get();
|
||
|
||
$metrics = app(SalesMetricsService::class);
|
||
$earnings = app(SalesEarningsService::class);
|
||
|
||
// Снимки тарифов привязок для отображаемых клиентов — доход по каждому.
|
||
// Клиент без привязки (начальник видит и незакреплённых) → earned_rub = null.
|
||
$assignmentsByTenant = SalesClientAssignment::query()
|
||
->whereIn('tenant_id', $rows->pluck('tenant_id')->all())
|
||
->get()
|
||
->keyBy('tenant_id');
|
||
|
||
$data = $rows->map(function (object $row) use ($metrics, $earnings, $period, $assignmentsByTenant): array {
|
||
$tenantId = (int) $row->tenant_id;
|
||
|
||
$assignment = $assignmentsByTenant->get($tenantId);
|
||
|
||
// projects_count: все проекты тенанта (без фильтра по is_active/archived).
|
||
// Counting all projects per tenant — active filter can be added if spec clarified.
|
||
$projectsCount = DB::table('projects')
|
||
->where('tenant_id', $tenantId)
|
||
->count();
|
||
|
||
// Производный статус — зеркалит AdminTenantsController CASE-логику.
|
||
$derivedStatus = $this->deriveTenantStatus($row);
|
||
|
||
return [
|
||
'tenant_id' => $tenantId,
|
||
'organization_name' => $row->organization_name,
|
||
'inn' => $row->inn,
|
||
'subject_type' => $row->subject_type,
|
||
'last_activity_at' => $row->last_activity_at !== null
|
||
? CarbonImmutable::parse($row->last_activity_at)->toIso8601String()
|
||
: null,
|
||
'balance_rub' => (string) $row->balance_rub,
|
||
'status' => $derivedStatus,
|
||
'tariff_name' => $row->tariff_name,
|
||
'projects_count' => $projectsCount,
|
||
'runway_days' => $metrics->runwayDays($tenantId),
|
||
'leads_delivered' => $metrics->leadsDelivered($tenantId, $period),
|
||
'oborot_rub' => $metrics->oborotRub($tenantId, $period),
|
||
'earned_rub' => $assignment !== null
|
||
? round($earnings->forAssignment($assignment, $period), 2)
|
||
: null,
|
||
];
|
||
})->all();
|
||
|
||
return response()->json(['data' => $data]);
|
||
}
|
||
|
||
/**
|
||
* Сводка менеджера / начальника.
|
||
*
|
||
* GET /api/sales/overview
|
||
*
|
||
* Менеджер видит агрегаты только по своим клиентам.
|
||
* Начальник (role=head) видит агрегаты по всем клиентам.
|
||
*
|
||
* Ответ:
|
||
* totals — агрегаты по клиентам за период
|
||
* attention — клиенты, требующие внимания (overdue / suspended / runway=0), до 10, worst-first
|
||
* top_clients — топ-4 клиента по leads_delivered за период, desc
|
||
*
|
||
* Производный статус (зеркалит index / AdminTenantsController):
|
||
* is_trial=true → trial
|
||
* status=suspended → suspended
|
||
* balance<0 / chargeback>0 → overdue
|
||
* status=active → active
|
||
*
|
||
* Spec: docs/superpowers/plans/2026-06-30-sales-portal.md (Task 1.5)
|
||
*/
|
||
public function overview(Request $request): JsonResponse
|
||
{
|
||
/** @var SalesUser $user */
|
||
$user = $request->user('sales');
|
||
|
||
// 1. Период
|
||
$period = app(SalesPeriodResolver::class)->resolve([
|
||
'kind' => $request->query('period', 'this'),
|
||
'from' => $request->query('from'),
|
||
'to' => $request->query('to'),
|
||
]);
|
||
|
||
// 2. Tenant scope (null = начальник = без ограничения)
|
||
$ids = $this->ownedTenantIds($user);
|
||
|
||
// 3. Базовый запрос тенантов
|
||
$query = DB::table('tenants')
|
||
->whereNull('tenants.deleted_at')
|
||
->select([
|
||
'tenants.id as tenant_id',
|
||
'tenants.organization_name',
|
||
'tenants.status',
|
||
'tenants.is_trial',
|
||
'tenants.balance_rub',
|
||
'tenants.chargeback_unrecovered_rub',
|
||
]);
|
||
|
||
if ($ids !== null) {
|
||
$query->whereIn('tenants.id', $ids === [] ? [-1] : $ids);
|
||
}
|
||
|
||
$rows = $query->get();
|
||
|
||
if ($rows->isEmpty()) {
|
||
return response()->json([
|
||
'totals' => [
|
||
'clients_count' => 0,
|
||
'active' => 0,
|
||
'trial' => 0,
|
||
'overdue' => 0,
|
||
'balance_sum_rub' => '0.00',
|
||
'leads_delivered' => 0,
|
||
'oborot_rub' => 0.0,
|
||
'earned_rub' => 0.0,
|
||
],
|
||
'attention' => [],
|
||
'top_clients' => [],
|
||
]);
|
||
}
|
||
|
||
$metrics = app(SalesMetricsService::class);
|
||
|
||
// Суммарный доход (комиссия) по всем привязкам в scope — без оклада менеджера.
|
||
$earnings = app(SalesEarningsService::class);
|
||
$earnedSum = 0.0;
|
||
foreach (
|
||
SalesClientAssignment::query()
|
||
->whereIn('tenant_id', $rows->pluck('tenant_id')->all())
|
||
->get() as $assignment
|
||
) {
|
||
$earnedSum += $earnings->forAssignment($assignment, $period);
|
||
}
|
||
|
||
// 4. Обогащаем строки: derived status + метрики периода
|
||
$enriched = $rows->map(function (object $row) use ($metrics, $period): array {
|
||
$tenantId = (int) $row->tenant_id;
|
||
|
||
$derivedStatus = $this->deriveTenantStatus($row);
|
||
|
||
$leadsDelivered = $metrics->leadsDelivered($tenantId, $period);
|
||
$oborotRub = $metrics->oborotRub($tenantId, $period);
|
||
$runwayDays = $metrics->runwayDays($tenantId);
|
||
|
||
return [
|
||
'tenant_id' => $tenantId,
|
||
'organization_name' => $row->organization_name,
|
||
'status' => $derivedStatus,
|
||
'balance_rub' => (string) $row->balance_rub,
|
||
'leads_delivered' => $leadsDelivered,
|
||
'oborot_rub' => $oborotRub,
|
||
'runway_days' => $runwayDays,
|
||
];
|
||
});
|
||
|
||
// 5. Агрегаты totals
|
||
$clientsCount = $enriched->count();
|
||
$activeCount = $enriched->where('status', 'active')->count();
|
||
$trialCount = $enriched->where('status', 'trial')->count();
|
||
$overdueCount = $enriched->where('status', 'overdue')->count();
|
||
|
||
// balance_sum_rub — суммируем как float, возвращаем строкой (как balance_rub у тенанта)
|
||
$balanceSum = $enriched->sum(fn (array $r): float => (float) $r['balance_rub']);
|
||
|
||
$totalLeads = (int) $enriched->sum('leads_delivered');
|
||
$totalOborot = (float) $enriched->sum('oborot_rub');
|
||
|
||
// 6. attention — overdue / suspended / runway_days === 0; worst-first; ≤10
|
||
// runway_days === 0 только для active/trial (у overdue/suspended уже плохо).
|
||
// runway_days === null означает «нет активных проектов» — НЕ попадает в attention.
|
||
// Приоритет: overdue > suspended > runway=0
|
||
$attentionRows = $enriched->filter(
|
||
fn (array $r): bool => in_array($r['status'], ['overdue', 'suspended'], true)
|
||
|| ($r['runway_days'] === 0 && in_array($r['status'], ['active', 'trial'], true)),
|
||
);
|
||
|
||
// Сортировка worst-first: overdue → suspended → runway=0 → balance desc
|
||
$statusPriority = ['overdue' => 0, 'suspended' => 1];
|
||
$attentionSorted = $attentionRows->sortBy([
|
||
fn (array $a, array $b): int => ($statusPriority[$a['status']] ?? 2) <=> ($statusPriority[$b['status']] ?? 2),
|
||
fn (array $a, array $b): int => (float) $a['balance_rub'] <=> (float) $b['balance_rub'],
|
||
])->values()->take(10);
|
||
|
||
$attention = $attentionSorted->map(fn (array $r): array => [
|
||
'tenant_id' => $r['tenant_id'],
|
||
'organization_name' => $r['organization_name'],
|
||
'status' => $r['status'],
|
||
'balance_rub' => $r['balance_rub'],
|
||
'runway_days' => $r['runway_days'],
|
||
])->values()->all();
|
||
|
||
// 7. top_clients — топ-4 по leads_delivered за период, desc
|
||
$topClients = $enriched
|
||
->sortByDesc('leads_delivered')
|
||
->take(4)
|
||
->values()
|
||
->map(fn (array $r): array => [
|
||
'tenant_id' => $r['tenant_id'],
|
||
'organization_name' => $r['organization_name'],
|
||
'leads_delivered' => $r['leads_delivered'],
|
||
'oborot_rub' => $r['oborot_rub'],
|
||
])
|
||
->values()
|
||
->all();
|
||
|
||
return response()->json([
|
||
'totals' => [
|
||
'clients_count' => $clientsCount,
|
||
'active' => $activeCount,
|
||
'trial' => $trialCount,
|
||
'overdue' => $overdueCount,
|
||
'balance_sum_rub' => number_format($balanceSum, 2, '.', ''),
|
||
'leads_delivered' => $totalLeads,
|
||
'oborot_rub' => $totalOborot,
|
||
'earned_rub' => round($earnedSum, 2),
|
||
],
|
||
'attention' => $attention,
|
||
'top_clients' => $topClients,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Карточка клиента.
|
||
*
|
||
* GET /api/sales/clients/{tenantId}
|
||
*
|
||
* Менеджер может открыть только своего клиента (иначе 403).
|
||
* Начальник открывает любого.
|
||
*
|
||
* Ответ:
|
||
* profile — анкетные данные тенанта + реквизиты
|
||
* kpi — текущий баланс, runway, счётчики за период
|
||
* projects — список проектов тенанта
|
||
* leads_by_day — лиды по дням (last ~14 дней или в рамках периода)
|
||
* recent_leads — последние ~20 лидов (телефоны МАСКИРОВАНЫ)
|
||
* activity — последние ~10 balance_transactions
|
||
*/
|
||
public function show(Request $request, int $tenantId): JsonResponse
|
||
{
|
||
/** @var SalesUser $user */
|
||
$user = $request->user('sales');
|
||
|
||
// 1. Проверка ownership: менеджер может смотреть только своих клиентов
|
||
$ids = $this->ownedTenantIds($user);
|
||
if ($ids !== null && ! in_array($tenantId, $ids, true)) {
|
||
abort(403, 'Этот клиент не закреплён за вами.');
|
||
}
|
||
|
||
// 2. Период для KPI-метрик
|
||
$period = app(SalesPeriodResolver::class)->resolve([
|
||
'kind' => $request->query('period', 'this'),
|
||
'from' => $request->query('from'),
|
||
'to' => $request->query('to'),
|
||
]);
|
||
|
||
// 3. Основные данные тенанта + реквизиты
|
||
$tenant = DB::table('tenants')
|
||
->leftJoin('tenant_requisites', 'tenant_requisites.tenant_id', '=', 'tenants.id')
|
||
->where('tenants.id', $tenantId)
|
||
->whereNull('tenants.deleted_at')
|
||
->select([
|
||
'tenants.id',
|
||
'tenants.organization_name',
|
||
'tenants.contact_email',
|
||
'tenants.desired_daily_numbers',
|
||
'tenants.balance_rub',
|
||
'tenants.last_activity_at',
|
||
'tenants.created_at',
|
||
'tenants.status',
|
||
'tenants.is_trial',
|
||
'tenants.chargeback_unrecovered_rub',
|
||
'tenant_requisites.contact_name',
|
||
'tenant_requisites.contact_phone',
|
||
'tenant_requisites.inn',
|
||
'tenant_requisites.subject_type',
|
||
'tenant_requisites.legal_address',
|
||
])
|
||
->first();
|
||
|
||
if ($tenant === null) {
|
||
abort(404, 'Клиент не найден.');
|
||
}
|
||
|
||
// 4. Метрики
|
||
$metrics = app(SalesMetricsService::class);
|
||
$leadsDelivered = $metrics->leadsDelivered($tenantId, $period);
|
||
$oborotRub = $metrics->oborotRub($tenantId, $period);
|
||
$runwayDays = $metrics->runwayDays($tenantId);
|
||
|
||
// Доход менеджера с этого клиента за период — по снимку тарифа привязки.
|
||
// Клиент без привязки (начальник открыл незакреплённого) → null.
|
||
$assignment = SalesClientAssignment::query()->where('tenant_id', $tenantId)->first();
|
||
$earnedRub = $assignment !== null
|
||
? round(app(SalesEarningsService::class)->forAssignment($assignment, $period), 2)
|
||
: null;
|
||
|
||
// projects_count: все проекты тенанта
|
||
$projectsCount = DB::table('projects')
|
||
->where('tenant_id', $tenantId)
|
||
->count();
|
||
|
||
// leads_target: сумма daily_limit_target активных проектов × число дней в периоде
|
||
$totalDailyTarget = (int) DB::table('projects')
|
||
->where('tenant_id', $tenantId)
|
||
->where('is_active', true)
|
||
->sum('daily_limit_target');
|
||
|
||
$daysInPeriod = (int) max(1, $period->start->diffInDays($period->end) + 1);
|
||
$leadsTarget = $totalDailyTarget * $daysInPeriod;
|
||
|
||
$avgLeadPriceRub = $oborotRub / max(1, $leadsDelivered);
|
||
|
||
// 5. Проекты
|
||
$projects = DB::table('projects')
|
||
->where('tenant_id', $tenantId)
|
||
->orderBy('id')
|
||
->limit(100)
|
||
->get()
|
||
->map(fn (object $p): array => [
|
||
'id' => (int) $p->id,
|
||
'name' => $p->name,
|
||
'signal_type' => $p->signal_type,
|
||
'region' => $p->regions ?? [],
|
||
'daily_limit_target' => (int) $p->daily_limit_target,
|
||
'delivered_today' => (int) $p->delivered_today,
|
||
'status' => (bool) $p->is_active ? 'active' : 'paused',
|
||
])
|
||
->all();
|
||
|
||
// 6. Лиды по дням (последние 14 дней)
|
||
// Оборот за каждый день подтягиваем одним запросом из lead_charges,
|
||
// сгруппированным по дню, и мержим с результатами deals.
|
||
$last14Start = CarbonImmutable::now('Europe/Moscow')->subDays(13)->startOfDay();
|
||
$last14End = CarbonImmutable::now('Europe/Moscow')->startOfDay()->addDay(); // завтра 00:00
|
||
|
||
$leadsByDayRows = DB::table('deals')
|
||
->where('tenant_id', $tenantId)
|
||
->whereNull('deleted_at')
|
||
->where('is_test', false)
|
||
->where('received_at', '>=', $last14Start)
|
||
->select([
|
||
DB::raw("DATE(received_at AT TIME ZONE 'Europe/Moscow') as day"),
|
||
DB::raw('COUNT(*) as cnt'),
|
||
])
|
||
->groupBy('day')
|
||
->orderBy('day')
|
||
->get();
|
||
|
||
// lead_charges за те же 14 дней, сгруппированные по дню (МСК)
|
||
$chargesByDayRows = DB::table('lead_charges')
|
||
->where('tenant_id', $tenantId)
|
||
->where('charged_at', '>=', $last14Start)
|
||
->where('charged_at', '<', $last14End)
|
||
->select([
|
||
DB::raw("DATE(charged_at AT TIME ZONE 'Europe/Moscow') as day"),
|
||
DB::raw('SUM(price_per_lead_kopecks) as sum_kopecks'),
|
||
])
|
||
->groupBy('day')
|
||
->get()
|
||
->keyBy('day');
|
||
|
||
$leadsByDayFormatted = $leadsByDayRows->map(function (object $row) use ($chargesByDayRows): array {
|
||
$dayStr = (string) $row->day;
|
||
$sumKopecks = isset($chargesByDayRows[$dayStr])
|
||
? (int) $chargesByDayRows[$dayStr]->sum_kopecks
|
||
: 0;
|
||
|
||
return [
|
||
'date' => $dayStr,
|
||
'count' => (int) $row->cnt,
|
||
'oborot_rub' => $sumKopecks / 100,
|
||
];
|
||
})->all();
|
||
|
||
// 7. Последние лиды (~20), телефоны маскированы
|
||
$recentLeads = DB::table('deals')
|
||
->leftJoin('projects', 'projects.id', '=', 'deals.project_id')
|
||
->where('deals.tenant_id', $tenantId)
|
||
->whereNull('deals.deleted_at')
|
||
->where('deals.is_test', false)
|
||
->orderByDesc('deals.received_at')
|
||
->limit(20)
|
||
->select([
|
||
'deals.received_at',
|
||
'deals.phone',
|
||
'deals.region_code',
|
||
'deals.city',
|
||
'projects.name as project_name',
|
||
'projects.signal_type',
|
||
])
|
||
->get()
|
||
->map(fn (object $d): array => [
|
||
'received_at' => CarbonImmutable::parse($d->received_at)->toIso8601String(),
|
||
'phone_masked' => $this->maskPhone($d->phone),
|
||
'region' => $d->city ?? $d->region_code,
|
||
'source' => ($d->project_name ?? '—').($d->signal_type !== null ? ' / '.$d->signal_type : ''),
|
||
'project' => $d->project_name,
|
||
])
|
||
->all();
|
||
|
||
// 8. Активность — последние 10 balance_transactions
|
||
$activity = DB::table('balance_transactions')
|
||
->where('tenant_id', $tenantId)
|
||
->orderByDesc('created_at')
|
||
->orderByDesc('id')
|
||
->limit(10)
|
||
->select(['created_at', 'type', 'amount_rub', 'description'])
|
||
->get()
|
||
->map(fn (object $tx): array => [
|
||
'created_at' => CarbonImmutable::parse($tx->created_at)->toIso8601String(),
|
||
'type' => $tx->type,
|
||
'amount_rub' => (string) $tx->amount_rub,
|
||
'description' => $tx->description,
|
||
])
|
||
->all();
|
||
|
||
return response()->json([
|
||
'profile' => [
|
||
'organization_name' => $tenant->organization_name,
|
||
'contact_email' => $tenant->contact_email,
|
||
'contact_name' => $tenant->contact_name,
|
||
'contact_phone' => $tenant->contact_phone,
|
||
'inn' => $tenant->inn,
|
||
'subject_type' => $tenant->subject_type,
|
||
'created_at' => $tenant->created_at !== null
|
||
? CarbonImmutable::parse($tenant->created_at)->toIso8601String()
|
||
: null,
|
||
'desired_daily_numbers' => $tenant->desired_daily_numbers,
|
||
'last_activity_at' => $tenant->last_activity_at !== null
|
||
? CarbonImmutable::parse($tenant->last_activity_at)->toIso8601String()
|
||
: null,
|
||
],
|
||
'kpi' => [
|
||
'balance_rub' => (string) $tenant->balance_rub,
|
||
'runway_days' => $runwayDays,
|
||
'projects_count' => $projectsCount,
|
||
'leads_delivered' => $leadsDelivered,
|
||
'leads_target' => $leadsTarget,
|
||
'avg_lead_price_rub' => $avgLeadPriceRub,
|
||
'earned_rub' => $earnedRub,
|
||
],
|
||
'projects' => $projects,
|
||
'leads_by_day' => $leadsByDayFormatted,
|
||
'recent_leads' => $recentLeads,
|
||
'activity' => $activity,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Маска телефона по 152-ФЗ: видны первые 2 цифры и 2 последних.
|
||
*
|
||
* Пример: «79161234567» → «79** *** ** 67»
|
||
*
|
||
* Зеркало AdminLeadsController::maskPhone — единый подход к маскированию ПДн.
|
||
*/
|
||
private function maskPhone(?string $phone): string
|
||
{
|
||
if (! $phone) {
|
||
return '—';
|
||
}
|
||
$digits = preg_replace('/\D/', '', $phone);
|
||
if (strlen((string) $digits) < 4) {
|
||
return '***';
|
||
}
|
||
$last2 = substr((string) $digits, -2);
|
||
$first = substr((string) $digits, 0, 2);
|
||
|
||
return $first.'** *** ** '.$last2;
|
||
}
|
||
}
|