9 Commits

Author SHA1 Message Date
Дмитрий aeb6164ed2 fix(autopodbor): RLS-политика межтенантного распорядителя + фронт 422-дискриминатор + price=0 гейт
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 18:57:57 +03:00
Дмитрий 07110557a1 feat(autopodbor): кнопка «Собрать источники (N)» в подвале + яркий алерт баланса 2026-07-08 18:30:10 +03:00
Дмитрий 0749e13d4e feat(autopodbor): endpoint POST /api/autopodbor/study/batch 2026-07-08 18:19:54 +03:00
Дмитрий d228a501dc feat(autopodbor): джоба зовёт распорядителя после завершения + крон autopodbor:study-tick
Задача 5: RunAutopodborStudyJob::handle() оборачивает существующее тело в
try/finally — после ЛЮБОГО завершения прогона (done/empty/failed, включая
проброшенное исключение) распорядитель AutopodborStudyScheduler::tick()
берёт следующего кандидата с освободившейся дорожки шага 2.

Задача 6: artisan autopodbor:study-tick — крон-страховка раз в минуту на
случай, если дорожка освободилась не через завершение джобы. Без
withoutOverlapping() — требует таблицу cache_locks, которой нет в схеме
(см. комментарий в routes/console.php); overlap уже защищён внутренним
Cache::lock в AutopodborStudyScheduler::tick().
2026-07-08 18:15:23 +03:00
Дмитрий 097f7673ee feat(autopodbor): startStudyBatch + перевод одиночных сборов на распорядителя 2026-07-08 17:47:43 +03:00
Дмитрий 2d5f91269a feat(autopodbor): AutopodborStudyScheduler — одна дорожка + round-robin 2026-07-08 17:35:54 +03:00
Дмитрий e9b4f78ec0 feat(autopodbor): AutopodborBudgetGate — деньги пакета с резервом проектов
Переиспользует BalancePreflightService.evaluate() для проверки, что после
списания за пакет ещё хватает баланса на дневной лимит активных проектов.
Бинарный поиск максимально доступного количества единиц пакета (maxAffordable)
и расчёт недостающей суммы (topupRub) при отказе.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 17:28:58 +03:00
Дмитрий 861b969ec4 feat(autopodbor): миграция batch_id + индексы очереди шага 2 2026-07-08 17:24:02 +03:00
Дмитрий edad482e49 feat(metrika): Метрика+Вебвизор в линию портала (чтобы выкаты не теряли)
Метрика была потеряна, т.к. клалась мимо конвейера: выкат = git archive HEAD ветки
+ пересборка, а Метрика жила отдельно → перезатёрлась. Кладём в feat/portal-mobile-adaptive
(линию, из которой режутся deploy/*-ветки) → любой будущий выкат несёт её сам.

Загрузчик metrika.ts (только кабинет layout=app), маскировка ПДн лидов
(ym-hide-content/ym-disable-keys) — десктоп-таблица + МОБИЛЬНЫЕ карточки (телефон+
комментарий+aria-label) + канбан + карточка сделки + диалог. Сборка проходит.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 16:49:45 +03:00
30 changed files with 103 additions and 926 deletions
@@ -33,8 +33,7 @@ class IncidentsWatchFailures extends Command
{--threshold-daily=50 : Порог суммы за 24ч для failed_jobs}
{--persistent-hours=3 : Порог возраста persistent-exception для failed_jobs}
{--dedup-window=60 : Окно дедупа открытых инцидентов в минутах}
{--threshold-single-lead=1000 : Порог storm detection: failures одного supplier_lead_id за окно}
{--threshold-manual-queue=10 : Порог spike ручной очереди поставщика (за окно) возможное падение кабинета}';
{--threshold-single-lead=1000 : Порог storm detection: failures одного supplier_lead_id за окно}';
protected $description = 'Сканирует failed_webhook_jobs и failed_jobs, создаёт incidents_log на превышение порогов';
@@ -48,7 +47,6 @@ class IncidentsWatchFailures extends Command
$dedupMinutes = (int) $this->option('dedup-window');
$thresholdSingleLead = (int) $this->option('threshold-single-lead');
$thresholdManualQueue = (int) $this->option('threshold-manual-queue');
$since = Carbon::now()->subMinutes($windowMinutes);
$since24h = Carbon::now()->subHours(24);
@@ -223,35 +221,6 @@ class IncidentsWatchFailures extends Command
}
}
// ===== БЛОК 6: supplier-manual-queue spike =====
// Пачка проектов, ушедших в ручную очередь за короткое окно = вероятное падение
// кабинета поставщика. Инцидент дедупим (60 мин, чтобы не засорять журнал), НО письмо
// шлём на КАЖДОМ прогоне пока спайк держится — «сигналит как ненормальный» при реальной
// аварии, одним сводным письмом на оба адреса (не N писем на проект).
if ($thresholdManualQueue > 0) {
$manualQueueCount = (int) DB::connection(self::DB_CONNECTION)
->table('supplier_manual_sync_queue')
->where('created_at', '>=', $since)
->count();
if ($manualQueueCount >= $thresholdManualQueue) {
$dedupKey = 'supplier-manual-queue-spike';
$summary = "Похоже, кабинет поставщика упал: {$manualQueueCount} проект(ов) ушло в ручную очередь за {$windowMinutes} мин.";
// Инцидент — только если открытого с той же сигнатурой нет (дедуп 60 мин).
if (! $this->isDup($dedupKey, $dedupAt)) {
$this->createIncident($adminId, 'other', 'high', $summary, $since, $now, $dedupKey, sendMail: false);
$created++;
}
// Письмо — каждый прогон при активном спайке, на оба адреса.
Mail::to(['kdv1@bk.ru', 'ops@liderra.ru'])
->send(new IncidentDetectedMail($summary, 'high'));
$this->info("Supplier manual-queue spike [high]: {$manualQueueCount} in {$windowMinutes}m");
}
}
$this->info("Done. Created {$created} incident(s).");
return self::SUCCESS;
@@ -276,7 +245,6 @@ class IncidentsWatchFailures extends Command
Carbon $startedAt,
Carbon $now,
string $dedupKey = '',
bool $sendMail = true,
): void {
DB::connection(self::DB_CONNECTION)->table('incidents_log')->insert([
'type' => $type,
@@ -291,7 +259,7 @@ class IncidentsWatchFailures extends Command
'updated_at' => $now,
]);
if ($sendMail && $severity === 'high') {
if ($severity === 'high') {
Mail::to('kdv1@bk.ru')->send(new IncidentDetectedMail($summary, $severity));
}
}
@@ -14,7 +14,6 @@ use App\Models\SupplierLeadCost;
use App\Models\User;
use App\Services\Pd\PdAuditLogger;
use App\Services\SupplierResolver;
use App\Support\SupplierProjectName;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
@@ -232,7 +231,7 @@ class DealController extends Controller
'id' => $d->id,
'tenant_id' => $d->tenant_id,
'project_id' => $d->project_id,
'project_name' => SupplierProjectName::strip($d->project?->name),
'project_name' => $d->project?->name,
'phone' => $d->phone,
'contact_name' => $d->contact_name,
'status' => $d->status,
@@ -344,7 +343,7 @@ class DealController extends Controller
'id' => $deal->id,
'tenant_id' => $deal->tenant_id,
'project_id' => $deal->project_id,
'project_name' => SupplierProjectName::strip($deal->project?->name),
'project_name' => $deal->project?->name,
'phone' => $deal->phone,
'contact_name' => $deal->contact_name,
'comment' => $deal->comment,
@@ -8,7 +8,6 @@ use App\Http\Controllers\Controller;
use App\Models\Deal;
use App\Services\Pd\PdAuditLogger;
use App\Support\CsvFormulaGuard;
use App\Support\SupplierProjectName;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
@@ -122,7 +121,7 @@ class DealExportController extends Controller
foreach ($deals as $deal) {
/** @var Deal $deal */
$signal = $deal->project?->signal_type;
$source = trim((SupplierProjectName::strip($deal->project?->name) ?? '—').' · '
$source = trim(($deal->project?->name ?? '—').' · '
.(self::SIGNAL_LABELS[$signal] ?? '—'));
// F-CSV: свободный текст (телефон/источник/город/статус/
// комментарий) экранируем от formula-инъекции. Дата —
@@ -6,7 +6,6 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Deal;
use App\Support\SupplierProjectName;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -88,7 +87,7 @@ class DealsController extends Controller
'contact_name' => $d->contact_name,
'city' => $d->city,
'status' => $d->status,
'project' => SupplierProjectName::strip($d->project?->name),
'project' => $d->project?->name,
])->all(),
'next_cursor' => $next,
]);
+39 -36
View File
@@ -100,32 +100,36 @@ final class CsvReconcileJob implements ShouldQueue
'created_at' => now(),
]);
// Гарантированный канал (Путь 2, переработан 09.07.2026): сверяемся с журналом
// ОТДАННОГО («Мои сделки», по vid), а НЕ с пулом «Запрос номеров» (phones_cnt).
// Инцидент roistat✓ 09.07: пул отдавал 157 собранных, из них поставщик отгрузил 15,
// а старый reconcile лепил из пула 15 фантомов + выедал лимит клиента. Сверка по vid
// (точная личность лида) исключает фантомы и ловит подмену при совпадении количества.
$delivered = $portal->fetchDeliveredLeads($windowStart, $windowEnd);
$totalCsvRows = count($delivered);
$reportId = $portal->requestNumbersReport($windowStart, $windowEnd);
$portal->waitReportReady($reportId);
$csv = $portal->downloadReport($reportId);
// Уже принятые нами vid'ы — проверяем ГЛОБАЛЬНО и точечно по самим отданным vid
// (не по окну): idx_supplier_leads_vid_unique глобальный, а точечный whereIn ограничен
// числом отданных (сотни). Так дедуп исключает любой существующий vid ДО вставки —
// unique-violation в норме не наступает (иначе abort транзакции).
$existingVids = [];
$deliveredVids = array_map('intval', array_keys($delivered));
if ($deliveredVids !== []) {
DB::connection(self::DB_CONNECTION)
->table('supplier_leads')
->whereIn('vid', $deliveredVids)
->pluck('vid')
->each(function ($vid) use (&$existingVids): void {
$existingVids[(int) $vid] = true;
});
// CSV-строки по ключу phone|project (последняя строка с тем же ключом перетирает).
/** @var array<string, array{project: string, tag: string, phone: string}> $csvByKey */
$csvByKey = [];
foreach ($parser->parse($csv) as $row) {
$csvByKey[$this->dedupKey((string) $row['phone'], (string) $row['project'])] = $row;
}
$totalCsvRows = count($csvByKey);
// Недостача = отданные поставщиком vid'ы, которых у нас нет (webhook потерял).
$missing = array_diff_key($delivered, $existingVids);
// Существующие лиды за окно → set ключей phone|project.
$existingKeys = [];
DB::connection(self::DB_CONNECTION)
->table('supplier_leads')
->where('received_at', '>=', $windowStart)
->select('phone', 'raw_payload')
->orderBy('id')
->chunk(500, function ($leads) use (&$existingKeys): void {
foreach ($leads as $lead) {
$payload = is_string($lead->raw_payload)
? json_decode($lead->raw_payload, true)
: (array) $lead->raw_payload;
$project = (string) ($payload['project'] ?? '');
$existingKeys[$this->dedupKey((string) $lead->phone, $project)] = true;
}
});
$missing = array_diff_key($csvByKey, $existingKeys);
$recoveredCount = 0;
$unparseableCount = 0;
@@ -145,18 +149,11 @@ final class CsvReconcileJob implements ShouldQueue
}
try {
// vid — НАСТОЯЩИЙ (из журнала отданного). idx_supplier_leads_vid_unique делает
// recovery идемпотентным с поздним webhook: SupplierWebhookController при том же
// vid вернёт существующий лид, второй сделки не будет.
$lead = SupplierLead::create([
'vid' => $row['vid'],
'vid' => null,
'platform' => $platform,
'phone' => (string) $row['phone'],
'raw_payload' => [
'project' => $row['project'],
'phone' => (string) $row['phone'],
'vid' => $row['vid'],
],
'raw_payload' => $row,
'received_at' => now(),
'recovered_from_csv_at' => now(),
'source' => 'csv_recovery',
@@ -165,10 +162,8 @@ final class CsvReconcileJob implements ShouldQueue
RouteSupplierLeadJob::dispatch($lead->id);
$recoveredCount++;
} catch (QueryException $e) {
// Гонка с webhook (тот же vid успел вставиться между chunk-снимком и insert'ом)
// ловится unique-индексом. Это не потеря — лид уже у нас, добор не нужен.
Log::info('csv_reconcile.lead_insert_skipped_race', [
'vid' => $row['vid'],
Log::warning('csv_reconcile.lead_insert_failed', [
'phone' => $row['phone'],
'project' => $row['project'],
'error' => $e->getMessage(),
]);
@@ -261,6 +256,14 @@ final class CsvReconcileJob implements ShouldQueue
}
}
/**
* Ключ дедупа: нормализованный phone + project.
*/
private function dedupKey(string $phone, string $project): string
{
return trim($phone).'|'.trim($project);
}
/** Был ли алерт о падении сверки за последнее окно троттла (анти-спам). */
private function failureAlertRecentlySent(): bool
{
@@ -134,10 +134,6 @@ class SyncSupplierProjectsJob implements ShouldQueue
$groups[$key]['projects'][] = $project;
}
// Активные сегодня источники (по слепку) — их заказы НЕ гасим в deactivation-проходе.
/** @var array<string, bool> $activeKeys */
$activeKeys = array_fill_keys(array_keys($groups), true);
// 3. Sync each group
try {
foreach ($groups as $group) {
@@ -196,13 +192,6 @@ class SyncSupplierProjectsJob implements ShouldQueue
continue;
}
}
// 4. Deactivation (money-critical, 18:00 ДО снимка поставщика в 21:00): гасим
// заказы источников, у которых на сегодняшний слепок не осталось ни одного
// активного проекта. Пропускаем при aborted (time-budget/mass-fail/auth).
if (! $aborted) {
$this->deactivateOrphanedOrders($activeKeys);
}
} finally {
$this->recordRunSummary(
startedAt: $startedAt,
@@ -216,103 +205,6 @@ class SyncSupplierProjectsJob implements ShouldQueue
}
}
/**
* Deactivation (18:00, money-critical owner-reported 2026-07-08, verified on prod live DB):
* гасим у поставщика заказы источников, у которых на СЕГОДНЯШНИЙ слепок не осталось ни
* одного активного проекта. Поставщик фиксирует завтрашнюю заливку своим снимком в 21:00,
* поэтому единственное окно остановить заказ этот 18:00-прогон ДО снимка. Без этого
* осиротевший заказ оставался включённым (галочка вкл, лимит > 0) поставщик лил лиды и
* списывал за остановленный клиентом проект.
*
* Активность из СЛЕПКА ($activeKeys = ключи sync-групп за завтра), НЕ из live is_active:
* слепок-инвариант проект, paused'нутый уже ПОСЛЕ снятия слепка, честно доезжает
* поставщику (см. collectEligibleProjects); гасим только то, чего в слепке нет.
*
* Осиротевший = supplier_project с external_id, inactive_since IS NULL, чей
* (signal_type|unique_key) не входит в $activeKeys. Шлём updateProject(status='paused') с
* ненулевым лимитом (кабинет отклоняет limit=0 даже при паузе), помечаем inactive_since
* ТОЛЬКО после успешной отправки (иначе ретрай следующим прогоном). Реактивацию при
* возврате проекта делают CleanupInactiveSupplierProjectsJob (Phase A) + syncGroup(active).
*
* @param array<string, bool> $activeKeys ключи «signal_type|identifier» активных групп
*/
private function deactivateOrphanedOrders(array $activeKeys): int
{
$paused = 0;
// get() (не cursor) — на shared PDO тестов курсор конфликтует с write'ами в цикле;
// набор ограничен (активные + ещё-не-погашенные осиротевшие заказы).
$candidates = SupplierProject::on(self::DB_CONNECTION)
->whereNotNull('supplier_external_id')
->whereNull('inactive_since')
->orderBy('id')
->get();
foreach ($candidates as $sp) {
if (isset($activeKeys[$sp->signal_type.'|'.$sp->unique_key])) {
continue; // источник ещё активен в сегодняшнем слепке — не трогаем
}
if (now()->timezone('Europe/Moscow')->format('H:i') >= self::TIME_BUDGET_CUTOFF) {
break; // время вышло — недогашенное подберёт следующий прогон
}
$regions = array_map('intval', (array) ($sp->current_regions ?? []));
$workdays = $sp->current_workdays !== null && $sp->current_workdays !== []
? array_map('intval', (array) $sp->current_workdays)
: [1, 2, 3, 4, 5, 6, 7];
$tag = count($regions) === 1
? (RussianRegions::CODE_TO_NAME[$regions[0]] ?? (string) $regions[0])
: 'РФ';
$dto = new SupplierProjectDto(
platform: $sp->platform,
signalType: (string) $sp->signal_type,
uniqueKey: (string) $sp->unique_key,
limit: max(1, (int) $sp->current_limit),
workdays: $workdays,
regions: $regions,
regionsReverse: false,
status: 'paused',
tag: $tag,
platforms: [$sp->platform],
);
try {
$this->channel->updateProject((int) $sp->supplier_external_id, $dto);
} catch (Throwable $e) {
// Transient/portal error — inactive_since НЕ ставим, ретрай следующим прогоном.
Log::warning('supplier.sync.deactivate_failed', [
'unique_key' => (string) $sp->unique_key,
'platform' => $sp->platform,
'error' => $e->getMessage(),
]);
continue;
}
$sp->forceFill([
'sync_status' => 'ok',
'last_synced_at' => now(),
'inactive_since' => now(),
])->save();
SupplierSyncLog::on(self::DB_CONNECTION)->create([
'supplier_project_id' => $sp->id,
'action' => 'update',
'http_status' => 200,
'created_at' => now(),
]);
$paused++;
}
if ($paused > 0) {
Log::info('supplier.sync.deactivated_orphaned', ['count' => $paused]);
}
return $paused;
}
/**
* Эпик 5: одна строка-сводка в supplier_sync_runs по завершении заливки.
* Пишется и при раннем abort (time-budget / mass-fail / auth) через finally.
+2
View File
@@ -21,6 +21,7 @@ use App\Services\Supplier\ProcessFactory;
use App\Services\Supplier\SymfonyProcessFactory;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
@@ -48,6 +49,7 @@ class AppServiceProvider extends ServiceProvider
fn ($app) => new FailoverProjectChannel(
$app->make(AjaxProjectChannel::class),
$app->make(FormProjectChannel::class),
$app->make(Mailer::class),
),
);
+6 -9
View File
@@ -13,11 +13,8 @@ use InvalidArgumentException;
* verify (App\Console\Commands\VerifyAuditChains) and rebuild
* (App\Console\Commands\AuditRebuildChain).
*
* ADR-021 (supersedes ADR-018): global-within-partition для ВСЕХ таблиц.
* Эмпирика на проде 2026-07-09 (все 4 бывшие «per-tenant» таблицы, включая
* balance_transactions): триггер `audit_chain_hash()` де-факто пишет глобальную
* цепочку (RLS не скоупит его SELECT) per-tenant изоляция ADR-018 никогда не
* была реализована. Verify/rebuild приведены к реальности (partition => '').
* ADR-018: per-tenant via RLS scope for tenant tables,
* global for BYPASSRLS tables.
*
* columns: list in ordinal_position order from db/schema.sql.
* '__log_hash__' -- marker for log_hash position -> NULL::bytea in ROW().
@@ -46,7 +43,7 @@ final class AuditChainConfig
'old_value', 'new_value', 'context', 'ip_address', 'user_agent',
'__log_hash__', 'created_at',
],
'partition' => '', // ADR-021: global-within-partition (per-tenant never реализован)
'partition' => 'PARTITION BY tenant_id',
],
'tenant_operations_log' => [
'columns' => [
@@ -54,7 +51,7 @@ final class AuditChainConfig
'event', 'payload_before', 'payload_after', 'ip_address', 'user_agent',
'__log_hash__', 'created_at',
],
'partition' => '', // ADR-021: global-within-partition (per-tenant never реализован)
'partition' => 'PARTITION BY tenant_id',
],
'balance_transactions' => [
'columns' => [
@@ -63,7 +60,7 @@ final class AuditChainConfig
'related_type', 'related_id', 'user_id', 'admin_user_id',
'__log_hash__', 'created_at',
],
'partition' => '', // ADR-021: global-within-partition (per-tenant never реализован)
'partition' => 'PARTITION BY tenant_id',
],
'pd_processing_log' => [
'columns' => [
@@ -71,7 +68,7 @@ final class AuditChainConfig
'purpose', 'actor_tenant_user_id', 'actor_admin_user_id', 'ip_address',
'__log_hash__', 'created_at',
],
'partition' => '', // ADR-021: global-within-partition (per-tenant never реализован)
'partition' => 'PARTITION BY tenant_id',
],
'saas_admin_audit_log' => [
'columns' => [
-65
View File
@@ -1,65 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\External;
/**
* Живость почты: TCP/TLS-connect к SMTP-порту Yandex 360 + чтение приветственного
* баннера (должен начинаться с «220»). Без логина/отправки денег/квоты не тратит.
* Соединитель инъектируется (тестируемость): возвращает первую строку баннера или бросает.
*/
class SmtpLivenessProbe implements LivenessProbe
{
/** @var (callable():string)|null */
private $connector;
/** @param (callable():string)|null $connector фейковый соединитель для тестов */
public function __construct(?callable $connector = null)
{
$this->connector = $connector;
}
public function serviceKey(): string
{
return 'email';
}
public function check(): LivenessReading
{
try {
$banner = ($this->connector ?? $this->defaultConnector())();
if (! str_starts_with(ltrim($banner), '220')) {
return LivenessReading::down('email', 'SMTP-баннер не 220: '.mb_substr(trim($banner), 0, 120));
}
return LivenessReading::alive('email', 'SMTP отвечает');
} catch (\Throwable $e) {
return LivenessReading::down('email', $e->getMessage());
}
}
/** @return callable():string */
private function defaultConnector(): callable
{
return function (): string {
$host = (string) config('services.smtp_probe.host');
$port = (int) config('services.smtp_probe.port');
$timeout = (int) config('services.smtp_probe.timeout', 5);
// 465 — implicit TLS; ssl:// нужен на connect.
$scheme = $port === 465 ? 'ssl://' : 'tcp://';
$fp = @stream_socket_client($scheme.$host.':'.$port, $errno, $errstr, $timeout);
if ($fp === false) {
throw new \RuntimeException($errstr !== '' ? $errstr : 'Connection refused (errno '.$errno.')');
}
try {
stream_set_timeout($fp, $timeout);
$line = fgets($fp, 512);
return $line === false ? '' : $line;
} finally {
fclose($fp);
}
};
}
}
@@ -7,11 +7,13 @@ namespace App\Services\Supplier\Channel;
use App\Exceptions\Supplier\SupplierAuthException;
use App\Exceptions\Supplier\SupplierClientException;
use App\Exceptions\Supplier\SupplierTransientException;
use App\Mail\SupplierCriticalAlertMail;
use App\Models\Project;
use App\Models\SupplierManualSyncQueue;
use App\Services\Supplier\Channel\Exceptions\TierEscalatedException;
use App\Services\Supplier\Channel\Exceptions\WindowDeferredException;
use App\Services\Supplier\Dto\SupplierProjectDto;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Support\Facades\Log;
use Throwable;
@@ -29,6 +31,7 @@ final class FailoverProjectChannel implements SupplierProjectChannel
public function __construct(
private readonly SupplierProjectChannel $tier1,
private readonly SupplierProjectChannel $tier2,
private readonly Mailer $mailer,
) {}
public function createProject(SupplierProjectDto $dto): int
@@ -78,6 +81,7 @@ final class FailoverProjectChannel implements SupplierProjectChannel
} catch (SupplierClientException|SupplierAuthException $e) {
try {
$id = $this->tier2->createProject($dto);
$this->alertFailoverToForm($project, 'create', $e);
return $id;
} catch (Throwable $tier2Error) {
@@ -104,6 +108,7 @@ final class FailoverProjectChannel implements SupplierProjectChannel
} catch (SupplierClientException|SupplierAuthException $e) {
try {
$this->tier2->updateProject($externalId, $dto);
$this->alertFailoverToForm($project, 'update', $e);
return;
} catch (Throwable $tier2Error) {
@@ -142,9 +147,24 @@ final class FailoverProjectChannel implements SupplierProjectChannel
'created_at' => now(),
]);
$this->mailer->to((string) config('services.supplier.alert_email'))
->queue(new SupplierCriticalAlertMail(
alertType: 'manual_required',
details: "Project #{$project->id} ({$dto->platform}/{$dto->uniqueKey}) — {$operation} queued #{$row->id}, reason: {$reason}. Cause: ".mb_substr($cause->getMessage(), 0, 300),
));
throw new TierEscalatedException($row->id, $reason);
}
private function alertFailoverToForm(Project $project, string $operation, Throwable $cause): void
{
$this->mailer->to((string) config('services.supplier.alert_email'))
->queue(new SupplierCriticalAlertMail(
alertType: 'failover_to_form',
details: "Project #{$project->id} {$operation}: Tier 1 (AJAX) failed, Tier 2 (browser) succeeded. Cause: ".mb_substr($cause->getMessage(), 0, 300),
));
}
/**
* Портальная сверка: ищет уже существующий проект на портале по тройке
* (platform, signal_type, unique_key). Возвращает external_id найденного
@@ -69,81 +69,6 @@ class SupplierPortalClient
return is_array($projects) ? array_values($projects) : [];
}
/** Строк на странице «Мои сделки» (index-visit) — портал отдаёт по 50. */
private const DELIVERED_PAGE_SIZE = 50;
/** Backstop от бесконечной пагинации (аномалия портала). */
private const DELIVERED_MAX_PAGES = 500;
/**
* Журнал ОТДАННОГО («Мои сделки») за окно [from, to] гарантированный канал доставки.
*
* В отличие от «Запрос номеров» (selectType=49), который отдаёт ПУЛ собранных номеров
* (`phones_cnt`, `prophones:'curr'`, `state_id:0`), эта страница содержит только реально
* ОТГРУЖЕННЫЕ поставщиком лиды каждый с настоящим `vid` (= checkbox value / view?id).
* Сверка по `vid` даёт точную личность лида: фантомы из пула невозможны, а совпадение
* количества при подменённых номерах (инцидент roistat✓ 09.07.2026: отдано 15 = сделок 15,
* но все 15 пул) ловится, чего потолок по `crms_cnt` не умеет.
*
* GET /admin/visit/index-visit?visit=rt&date_from&date_to&page=N server-rendered HTML,
* 50 строк/страница, newest-first. Пагинируем до страницы с < 50 строк.
* Verified live 2026-07-09: строка = `name="visit-checbox" value="<vid>"` +
* `B{1,2,3}_<ident>` (проект) + `7\d{10}` (телефон). date_from/date_to формат d.m.Y.
*
* @return array<int, array{vid: int, phone: string, project: string}> ключ = vid (дедуп)
*/
public function fetchDeliveredLeads(CarbonInterface $from, CarbonInterface $to): array
{
$byVid = [];
$page = 1;
do {
$response = $this->request('GET', '/admin/visit/index-visit', [
'visit' => 'rt',
'date_from' => $from->format('d.m.Y'),
'date_to' => $to->format('d.m.Y'),
'page' => $page,
]);
$rows = $this->parseDeliveredRows($response->body());
foreach ($rows as $row) {
$byVid[$row['vid']] = $row;
}
$page++;
} while (count($rows) >= self::DELIVERED_PAGE_SIZE && $page <= self::DELIVERED_MAX_PAGES);
return $byVid;
}
/**
* Парсит строки таблицы «Мои сделки» [vid, phone, project].
* Строки без vid (шапка/служебные) или без телефона пропускаются.
*
* @return list<array{vid: int, phone: string, project: string}>
*/
private function parseDeliveredRows(string $html): array
{
$out = [];
foreach (preg_split('/<tr[ >]/', $html) ?: [] as $row) {
if (preg_match('/name="visit-checbox"\s+value="(\d+)"/', $row, $vid) !== 1) {
continue;
}
if (preg_match('/(7\d{10})/', $row, $phone) !== 1) {
continue;
}
$project = preg_match('#(B[123]_[^\s<"]+)#', $row, $proj) === 1 ? $proj[1] : '';
$out[] = [
'vid' => (int) $vid[1],
'phone' => $phone[1],
'project' => $project,
];
}
return $out;
}
public function saveProject(SupplierProjectDto $dto): int
{
$response = $this->request(
-35
View File
@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support;
/**
* Утилита отображения имён проектов поставщика display-only.
*
* Поставщик префиксует имена проектов кодом канала-провайдера (B1_/B2_/B3_/B6_/B8_/B<N>_).
* Клиенту этот префикс показывать нельзя: он раскрывает нашу внутреннюю схему каналов и то,
* что лиды перекупаются. Срезаем префикс во ВСЕХ клиентских ответах СЕРВЕРНО (API, экспорт),
* а не только на фронте иначе прямой API-потребитель и скачанный CSV/XLSX всё равно видят «B1_…».
*
* Серверный аналог resources/js/composables/projectName.ts::stripChannelPrefix.
* Данные в БД (`supplier_projects.name` / `projects.name`) НЕ трогаем только вывод.
*/
final class SupplierProjectName
{
/** Любой B + одна-или-более цифр + подчёркивание в начале (B1_/B6_/B8_/B10_…), но не буква (BX_). */
private const CHANNEL_PREFIX_RE = '/^B\d+_/i';
/**
* Срезает канальный префикс из начала имени проекта.
* null null (не ломаем nullable-контракт API), '' '', остальное без префикса.
*/
public static function strip(?string $name): ?string
{
if ($name === null || $name === '') {
return $name;
}
return preg_replace(self::CHANNEL_PREFIX_RE, '', $name) ?? $name;
}
}
+1 -1
View File
@@ -1317,7 +1317,7 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
identifier: method.notFound
count: 9
count: 5
path: tests/Feature/Console/IncidentsWatchFailuresTest.php
-
@@ -6,8 +6,6 @@
*
* Billing v2 Spec A §3.4.8.
*/
import { tierRangeText } from '../../utils/pricingTiers';
interface TierPreview {
tier_no: number;
leads_in_tier: number | null;
@@ -20,7 +18,14 @@ const props = defineProps<{
}>();
function rangeText(idx: number): string {
return tierRangeText(props.tiers, idx);
let start = 1;
for (let i = 0; i < idx; i++) {
const cap = props.tiers[i].leads_in_tier;
if (cap !== null) start += cap;
}
const cap = props.tiers[idx].leads_in_tier;
if (cap === null) return `${start}+`;
return `${start}${start + cap - 1}`;
}
</script>
@@ -1,24 +1,22 @@
<script setup lang="ts">
/**
* Обёртка: десктоп (>=1280) — слот #table (обычный v-data-table),
* телефон и планшет (<1280) — слот #card (список карточек по каждому item).
* Широкие таблицы (Сделки/Списания) не влезают в узкую колонку планшета-портрета
* рядом с rail-меню — правые колонки обрезались, поэтому на планшете тоже карточки.
* Обёртка: десктоп/планшет — слот #table (обычный v-data-table),
* телефон — слот #card (список карточек по каждому item).
* Данные прокидываются через слот-пропсы, чтобы вызывающий сам рисовал карточку.
*/
import { useLayoutTier } from '../../composables/useLayoutTier';
defineProps<{ items: unknown[] }>();
const { isDesktop } = useLayoutTier();
const { isPhone } = useLayoutTier();
</script>
<template>
<div>
<template v-if="isDesktop">
<slot name="table" :items="items" />
<template v-if="isPhone">
<slot name="card" :items="items" />
</template>
<template v-else>
<slot name="card" :items="items" />
<slot name="table" :items="items" />
</template>
</div>
</template>
@@ -43,14 +43,6 @@ function hasSource(deal: MockDeal): boolean {
return Boolean(deal.sourceIdentifier || deal.sourceProjectId);
}
// «Конкурент» — только бренд, без описания: имя конкурента хранится как
// «Бренд, описание…», в колонке показываем часть до первой запятой.
function competitorShort(name: string | null | undefined): string {
if (!name) return '';
const i = name.indexOf(',');
return (i === -1 ? name : name.slice(0, i)).trim();
}
function formatDateTime(iso: string | null | undefined): string {
if (!iso) return '—';
const d = new Date(iso);
@@ -111,10 +103,9 @@ function toggleSelect(id: number): void {
<router-link
v-if="item.competitorId"
class="cell-link"
:title="item.competitorName ?? undefined"
:to="{ name: 'autopodbor', query: { competitor: String(item.competitorId) } }"
@click.stop
>{{ competitorShort(item.competitorName) }}</router-link
>{{ item.competitorName }}</router-link
>
<span v-else class="text-medium-emphasis">—</span>
</template>
@@ -208,10 +199,9 @@ function toggleSelect(id: number): void {
<router-link
v-if="deal.competitorId"
class="deal-card__competitor"
:title="deal.competitorName ?? undefined"
:to="{ name: 'autopodbor', query: { competitor: String(deal.competitorId) } }"
@click.stop
>{{ competitorShort(deal.competitorName) }}</router-link
>{{ deal.competitorName }}</router-link
>
<span v-else class="deal-card__project">{{ stripChannelPrefix(deal.project) }}</span>
<span v-if="!hasSource(deal) && signalLabel(deal.signalType)" class="deal-card__signal">{{
-33
View File
@@ -1,33 +0,0 @@
/**
* Единый источник истины для показа объёмных ступеней тарифа.
*
* В БД у каждой ступени хранится ШИРИНА (`leads_in_tier`) — сколько лидов
* помещается именно в эту ступень, а НЕ «до какого лида она действует».
* Живой расчёт списаний (PricingTierResolver / BalanceToLeadsConverter)
* накапливает эти ширины: tier 1 = 1..w1, tier 2 = w1+1..w1+w2, и т.д.
*
* Эта функция переводит ширину в тот накопительный диапазон, который видит
* клиент в кабинете. Держим её в ОДНОМ месте, чтобы админка и кабинет никогда
* не разошлись в цифрах.
*/
export interface TierWidth {
leads_in_tier: number | null;
}
/**
* Диапазон номеров лидов для ступени с индексом `idx` (0-based) в массиве
* ступеней, отсортированном по возрастанию tier_no.
*
* Пример (ширины 250/500/…): idx=0 → «1250», idx=1 → «251750».
* Последняя ступень (`leads_in_tier === null`) → «19251+».
*/
export function tierRangeText(tiers: ReadonlyArray<TierWidth>, idx: number): string {
let start = 1;
for (let i = 0; i < idx; i++) {
const cap = tiers[i].leads_in_tier;
if (cap !== null) start += cap;
}
const cap = tiers[idx].leads_in_tier;
if (cap === null) return `${start}+`;
return `${start}${start + cap - 1}`;
}
@@ -23,7 +23,7 @@
</span>
</v-card-title>
<v-data-table
:headers="activeHeaders"
:headers="tierHeaders"
:items="active"
:items-per-page="7"
density="comfortable"
@@ -33,17 +33,10 @@
<span v-if="item.leads_in_tier !== null">{{ item.leads_in_tier }}</span>
<span v-else class="text-medium-emphasis">все свыше</span>
</template>
<template #[`item.client_range`]="{ item }">
<span :data-testid="`active-range-${item.tier_no}`">{{ activeRangeText(item) }} лидов</span>
</template>
<template #[`item.price_rub`]="{ item }">
{{ fmtRub(item.price_per_lead_kopecks) }}
</template>
</v-data-table>
<div class="text-caption text-medium-emphasis px-4 pb-3">
«Лидов в ступени» сколько заявок помещается именно в эту ступень (ширина). «Диапазон»
как эти ступени видит клиент в кабинете: с какого по какой лид действует цена.
</div>
</v-card>
<v-card v-if="hasScheduled" class="mb-6" elevation="1">
@@ -75,16 +68,11 @@
style="max-width: 240px"
data-testid="effective-from-input"
/>
<p class="text-caption text-medium-emphasis mb-2">
В поле «Лидов в ступени» вводится <strong>ширина</strong> ступени (сколько заявок в неё
помещается). Справа сразу видно, <strong>что увидит клиент</strong> накопительный диапазон.
</p>
<table class="editor-table">
<thead>
<tr>
<th>Ступень</th>
<th>Лидов в ступени</th>
<th>Клиент увидит</th>
<th>Цена за лид ()</th>
</tr>
</thead>
@@ -102,9 +90,6 @@
/>
<span v-else class="text-medium-emphasis">все свыше</span>
</td>
<td :data-testid="`editor-range-${t.tier_no}`" class="editor-range num">
{{ editorRangeText(t) }} лидов
</td>
<td>
<v-text-field
v-model="t.price_rub"
@@ -163,7 +148,6 @@ import {
type PricingTierEditorRow,
} from '../../api/admin';
import { extractErrorMessage } from '../../api/client';
import { tierRangeText } from '../../utils/pricingTiers';
/**
* SaaS-admin → Тарифная сетка (Plan 4 Task 9, Sprint 1 G1 error handling).
@@ -214,28 +198,6 @@ const tierHeaders = [
{ title: 'Цена за лид', key: 'price_rub', sortable: false },
];
// Активная сетка показывает ещё и накопительный диапазон — ровно то, что видит
// клиент в кабинете (устраняет путаницу «в админке одни числа, у клиента другие»).
const activeHeaders = [
{ title: '№', key: 'tier_no', sortable: false, width: 80 },
{ title: 'Лидов в ступени', key: 'leads_in_tier', sortable: false },
{ title: 'Диапазон (у клиента)', key: 'client_range', sortable: false },
{ title: 'Цена за лид', key: 'price_rub', sortable: false },
];
// Диапазон для строки активной сетки: находим позицию ступени по tier_no
// и переводим ширины в накопительный диапазон (общий помощник — как в кабинете).
function activeRangeText(item: AdminPricingTier): string {
const idx = active.value.findIndex((t) => t.tier_no === item.tier_no);
return tierRangeText(active.value, idx);
}
// Живой предпросмотр диапазона в редакторе — пересчитывается при правке ширины.
function editorRangeText(row: PricingTierEditorRow): string {
const idx = editor.value.findIndex((r) => r.tier_no === row.tier_no);
return tierRangeText(editor.value, idx);
}
const nextMonthStart = computed(() => {
const d = new Date();
d.setDate(1);
@@ -345,12 +307,4 @@ defineExpose({
padding: 8px 12px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.num {
font-family: 'JetBrains Mono', monospace;
font-feature-settings: 'tnum';
}
.editor-range {
color: #6b6456;
white-space: nowrap;
}
</style>
@@ -2,13 +2,9 @@
declare(strict_types=1);
use App\Mail\IncidentDetectedMail;
use App\Models\Project;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
@@ -111,63 +107,3 @@ test('creates separate incidents for different exception signatures', function (
expect(DB::table('incidents_log')->count())->toBe(2);
});
// ===== Блок 6: supplier-manual-queue spike =====
// Helper: seed a project + N supplier_manual_sync_queue rows within the window
function seedManualQueue(int $n, ?DateTimeInterface $at = null): int
{
$project = Project::factory()->for(Tenant::factory())->create();
for ($i = 0; $i < $n; $i++) {
DB::table('supplier_manual_sync_queue')->insert([
'project_id' => $project->id,
'platform' => 'B1',
'operation' => 'create',
'external_id' => null,
'payload_snapshot' => '{}',
'failure_reason' => 'portal_unreachable',
'status' => 'pending',
'created_at' => $at ?? now(),
]);
}
return $project->id;
}
test('manual-queue spike below threshold: no incident, no mail', function () {
Mail::fake();
seedManualQueue(3, now()); // < default threshold 10
$this->artisan('incidents:watch-failures')->assertSuccessful();
expect(DB::table('incidents_log')->where('root_cause', 'supplier-manual-queue-spike')->count())->toBe(0);
Mail::assertNothingSent();
});
test('manual-queue spike at/above threshold: high incident + mail to both addresses', function () {
Mail::fake();
seedManualQueue(10, now()); // == default threshold 10
$this->artisan('incidents:watch-failures')->assertSuccessful();
$incident = DB::table('incidents_log')->where('root_cause', 'supplier-manual-queue-spike')->first();
expect($incident)->not->toBeNull();
expect($incident->severity)->toBe('high');
expect($incident->summary)->toContain('10');
Mail::assertSent(IncidentDetectedMail::class, function ($mail) {
return $mail->hasTo('kdv1@bk.ru') && $mail->hasTo('ops@liderra.ru');
});
});
test('manual-queue spike dedup: second run in window makes no new incident BUT re-sends mail', function () {
Mail::fake();
seedManualQueue(12, now());
$this->artisan('incidents:watch-failures')->assertSuccessful();
$this->artisan('incidents:watch-failures')->assertSuccessful();
// Инцидент один (дедуп 60 мин), а письмо ушло на каждом прогоне (громкость).
expect(DB::table('incidents_log')->where('root_cause', 'supplier-manual-queue-spike')->count())->toBe(1);
Mail::assertSent(IncidentDetectedMail::class, 2);
});
-14
View File
@@ -100,17 +100,3 @@ test('POST /api/deals/export нейтрализует CSV-формулы в св
expect($body)->not->toContain('"=HYPERLINK(');
expect($body)->not->toContain(';@SUM');
});
test('POST /api/deals/export срезает канальный префикс B<N>_ из источника (не палим поставщика)', function () {
$bgProject = Project::factory()->for($this->tenant)->create(['name' => 'B6_okna.ru [12]']);
Deal::factory()->for($this->tenant)->for($bgProject)->create([
'phone' => '+7 999 333-33-33', 'received_at' => '2026-05-15 10:00:00',
]);
$body = $this->post('/api/deals/export', [
'received_from' => '2026-05-14', 'received_to' => '2026-05-16', 'format' => 'csv',
])->streamedContent();
expect($body)->toContain('okna.ru [12]');
expect($body)->not->toContain('B6_');
});
-10
View File
@@ -493,13 +493,3 @@ test('GET /api/deals: источник чужого tenant не подтягив
$res = $this->getJson('/api/deals')->assertOk()->json('deals.0');
expect($res['competitor_name'])->toBeNull();
});
test('GET /api/deals срезает канальный префикс B<N>_ из project_name (не палим поставщика)', function () {
$bgProject = Project::factory()->for($this->tenant)->create(['name' => 'B6_okna.ru']);
Deal::factory()->for($this->tenant)->for($bgProject)->create(['status' => 'new']);
$r = $this->getJson('/api/deals');
$r->assertStatus(200);
expect($r->json('deals.0.project_name'))->toBe('okna.ru');
});
@@ -5,6 +5,7 @@ declare(strict_types=1);
use App\Exceptions\Supplier\SupplierAuthException;
use App\Exceptions\Supplier\SupplierClientException;
use App\Exceptions\Supplier\SupplierTransientException;
use App\Mail\SupplierCriticalAlertMail;
use App\Models\Project;
use App\Models\SupplierManualSyncQueue;
use App\Models\Tenant;
@@ -13,6 +14,7 @@ use App\Services\Supplier\Channel\Exceptions\WindowDeferredException;
use App\Services\Supplier\Channel\FailoverProjectChannel;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use App\Services\Supplier\Dto\SupplierProjectDto;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Mail;
@@ -44,6 +46,7 @@ function makeFailover(SupplierProjectChannel $tier1, ?SupplierProjectChannel $ti
return [];
}
},
app(Mailer::class),
);
}
@@ -122,7 +125,7 @@ it('createProject — Tier 1 transient-exhausted: skips Tier 2, jumps to Tier 3
expect($tier2Called)->toBeFalse();
expect(SupplierManualSyncQueue::where('project_id', $project->id)->where('failure_reason', 'portal_unreachable')->count())->toBe(1);
Mail::assertNothingQueued();
Mail::assertQueued(SupplierCriticalAlertMail::class);
});
it('createProject — Tier 1 client-exc → Tier 2 success: no queue', function (): void {
@@ -162,7 +165,7 @@ it('createProject — Tier 1 client-exc → Tier 2 success: no queue', function
expect($id)->toBe(800001);
expect(SupplierManualSyncQueue::count())->toBe(0);
Mail::assertNothingQueued(); // failover через форму — успех, не тревога
Mail::assertQueued(SupplierCriticalAlertMail::class); // failover_to_form alert
});
it('createProject — Tier 1 client-exc + Tier 2 fail: Tier 3 queue, manual_required alert', function (): void {
@@ -202,7 +205,7 @@ it('createProject — Tier 1 client-exc + Tier 2 fail: Tier 3 queue, manual_requ
->toThrow(TierEscalatedException::class);
expect(SupplierManualSyncQueue::where('project_id', $project->id)->where('status', 'pending')->count())->toBe(1);
Mail::assertNothingQueued();
Mail::assertQueued(SupplierCriticalAlertMail::class);
});
it('createProject — Tier 1 auth-exc → Tier 2 success', function (): void {
@@ -4,6 +4,7 @@ declare(strict_types=1);
use App\Exceptions\Supplier\SupplierClientException;
use App\Exceptions\Supplier\SupplierTransientException;
use App\Mail\SupplierCriticalAlertMail;
use App\Models\Project;
use App\Models\SupplierManualSyncQueue;
use App\Models\Tenant;
@@ -11,12 +12,13 @@ use App\Services\Supplier\Channel\Exceptions\TierEscalatedException;
use App\Services\Supplier\Channel\FailoverProjectChannel;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use App\Services\Supplier\Dto\SupplierProjectDto;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
uses(RefreshDatabase::class);
test('Tier-1 fail + Tier-2 fail → Tier-3 escalation creates manual queue row, no per-project alert mail', function (): void {
test('Tier-1 fail + Tier-2 fail → Tier-3 escalation creates manual queue row + queues alert mail', function (): void {
Mail::fake();
config(['services.supplier.alert_email' => 'ops@liderra.local']);
@@ -30,7 +32,7 @@ test('Tier-1 fail + Tier-2 fail → Tier-3 escalation creates manual queue row,
$tier2 = mock(SupplierProjectChannel::class);
$tier2->shouldReceive('createProject')->andThrow(new RuntimeException('Tier-2 manage-project.js selector break'));
$channel = new FailoverProjectChannel($tier1, $tier2);
$channel = new FailoverProjectChannel($tier1, $tier2, app(Mailer::class));
$dto = new SupplierProjectDto(
platform: 'B1', signalType: 'site', uniqueKey: 'failover-smoke.example',
@@ -42,7 +44,7 @@ test('Tier-1 fail + Tier-2 fail → Tier-3 escalation creates manual queue row,
expect(SupplierManualSyncQueue::where('project_id', $project->id)->count())->toBe(1);
Mail::assertNothingQueued(); // per-project тревога убрана — запись в ручной очереди достаточна
Mail::assertQueued(SupplierCriticalAlertMail::class, fn ($m) => $m->alertType === 'manual_required');
});
test('Tier-1 transient fail (portal unreachable) bypasses Tier-2 and goes straight to Tier-3', function (): void {
@@ -59,7 +61,7 @@ test('Tier-1 transient fail (portal unreachable) bypasses Tier-2 and goes straig
$tier2 = mock(SupplierProjectChannel::class);
$tier2->shouldNotReceive('createProject'); // КЛЮЧЕВОЕ — transient НЕ должен попасть в tier-2
$channel = new FailoverProjectChannel($tier1, $tier2);
$channel = new FailoverProjectChannel($tier1, $tier2, app(Mailer::class));
$dto = new SupplierProjectDto(
platform: 'B1', signalType: 'site', uniqueKey: 'transient-smoke.example',
@@ -577,113 +577,3 @@ test('nightly: re-creates donor on portal when its external_id no longer exists
expect($sps)->toHaveCount(3);
expect($sps->pluck('supplier_external_id')->all())->toBe(['8001', '8002', '8003']);
});
// ---------------------------------------------------------------------------
// Deactivation (18:00): turn OFF orders whose source has no active project left.
// Money-critical (owner-reported 2026-07-08, verified on prod live DB): prod runs
// in BATCH mode. The supplier fixes tomorrow's delivery by its 21:00 snapshot, so the
// ONLY window to STOP an order is the 18:00 batch sync — before that snapshot. This
// nightly job only processed ACTIVE projects and never turned OFF a source whose
// projects are all now paused → the paused order stayed active at the supplier → next
// day leads (and charges) kept coming. The 18:00 job must push status=paused for such
// orphaned orders before 21:00.
// ---------------------------------------------------------------------------
test('nightly 18:00: a source with no active project left is turned OFF (status=paused) at the supplier', function (): void {
$tenant = Tenant::factory()->create();
$common = '79997778899';
// Project is PAUSED → excluded from tomorrow's snapshot (SnapshotProjectRoutingJob
// filters is_active=true). Deliberately NO insertSnapshotForTomorrow — nothing active
// references this source tonight, so its supplier order is orphaned.
Project::factory()->create([
'tenant_id' => $tenant->id, 'is_active' => false, 'signal_type' => 'call',
'signal_identifier' => $common, 'daily_limit_target' => 6, 'delivery_days_mask' => 127, 'regions' => [],
]);
// Pre-seed the supplier orders created earlier when the project was live (numeric
// external ids so updateProject's (int) cast keeps id != 0).
foreach (['B1' => '90001', 'B2' => '90002', 'B3' => '90003'] as $platform => $ext) {
SupplierProject::on('pgsql_supplier')->create([
'platform' => $platform, 'signal_type' => 'call', 'unique_key' => $common,
'subject_code' => null, 'supplier_external_id' => $ext, 'current_limit' => 2,
'current_workdays' => [1, 2, 3, 4, 5, 6, 7], 'current_regions' => [], 'sync_status' => 'ok',
'last_synced_at' => now()->subDay(),
]);
}
$savePayloads = [];
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => function ($request) use (&$savePayloads) {
$savePayloads[] = $request->data();
return Http::response(['status' => 'OK', 'message' => '', 'id' => (string) ($request->data()['id'] ?? 0)], 200);
},
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => []], 200),
]);
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
// The 3 orphaned orders were turned OFF: update (id != 0) with status=false.
$offCalls = array_values(array_filter(
$savePayloads,
fn ($p) => (int) ($p['id'] ?? 0) !== 0 && ($p['status'] ?? null) === false
));
expect($offCalls)->toHaveCount(3);
// Non-zero limit kept (portal rejects limit=0 even when paused).
foreach ($offCalls as $p) {
expect((int) $p['limit'])->toBeGreaterThan(0);
}
// Local rows marked inactive_since so cleanup/UI reflect the pause.
expect(SupplierProject::on('pgsql_supplier')->where('unique_key', $common)->whereNotNull('inactive_since')->count())->toBe(3);
});
test('nightly 18:00: a deleted project leftover order is turned OFF but KEPT in DB while its snapshot tail still routes', function (): void {
// Composition of the new deactivation pass with delete-defer (DeleteSupplierProjectTailGuardTest):
// when a project is deleted but its donor is deferred (so already-ordered tail leads still
// route via the snapshot), the 18:00 pass must still turn the order OFF at the supplier
// (stop FUTURE ordering) — WITHOUT deleting the local donor (the tail still needs it).
$tenant = Tenant::factory()->create();
$common = '79996665544';
// Donor exists (created when the project was live). Project already hard-deleted → no pivot
// links, and NOT in tomorrow's active snapshot.
$sp = SupplierProject::on('pgsql_supplier')->create([
'platform' => 'B1', 'signal_type' => 'call', 'unique_key' => $common,
'subject_code' => null, 'supplier_external_id' => '95001', 'current_limit' => 3,
'current_workdays' => [1, 2, 3, 4, 5, 6, 7], 'current_regions' => [], 'sync_status' => 'ok',
'last_synced_at' => now()->subDay(),
]);
// Tail snapshot for the source (fake project_id → not an active project, mirrors the
// delete tail-guard fixture). LeadRouter still needs the donor for tail matching.
DB::table('project_routing_snapshots')->insert([
'snapshot_date' => Carbon::tomorrow('Europe/Moscow')->toDateString(),
'project_id' => 987654, 'tenant_id' => $tenant->id, 'daily_limit' => 5,
'delivery_days_mask' => 127, 'regions' => '{}', 'signal_type' => 'call',
'signal_identifier' => $common, 'sms_senders' => null, 'sms_keyword' => null,
'expected_volume' => 5, 'delivered_count' => 0, 'created_at' => now(),
]);
$savePayloads = [];
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => function ($request) use (&$savePayloads) {
$savePayloads[] = $request->data();
return Http::response(['status' => 'OK', 'message' => '', 'id' => (string) ($request->data()['id'] ?? 0)], 200);
},
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => []], 200),
]);
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
// Order turned OFF at the supplier (update id != 0, status=false) — stops future ordering.
$off = array_values(array_filter(
$savePayloads,
fn ($p) => (int) ($p['id'] ?? 0) !== 0 && ($p['status'] ?? null) === false
));
expect($off)->toHaveCount(1);
// The local donor record is KEPT (not deleted) so the snapshot tail still routes; marked inactive.
$fresh = SupplierProject::on('pgsql_supplier')->find($sp->id);
expect($fresh)->not->toBeNull();
expect($fresh->inactive_since)->not->toBeNull();
});
@@ -143,62 +143,6 @@ describe('AdminPricingTiersView', () => {
});
});
describe('AdminPricingTiersView — клиентский диапазон (устранение путаницы admin↔кабинет)', () => {
beforeEach(() => {
vi.mocked(adminApi.getPricingTiers).mockResolvedValue({ active: mockTiers, scheduled: {} });
vi.mocked(adminApi.createPricingTiers).mockResolvedValue({ effective_from: '2026-06-01' });
});
it('в активной сетке рядом с шириной показывает накопительный диапазон, как у клиента', async () => {
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
// ширины 100,200 → ступень 2 у клиента = 101–300 (а не «200»)
const cell = wrapper.find('[data-testid="active-range-2"]');
expect(cell.exists()).toBe(true);
expect(cell.text()).toContain('101300');
});
it('ступень 7 в активной сетке — открытый диапазон со знаком +', async () => {
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
// 100+200+400+800+1500+3000 = 6000 → ступень 7 = 6001+
expect(wrapper.find('[data-testid="active-range-7"]').text()).toContain('6001+');
});
it('редактор показывает живой клиентский диапазон рядом с полем ширины', async () => {
const wrapper = mount(AdminPricingTiersView, {
global: {
plugins: [vuetify],
stubs: { VDialog: { template: '<div><slot /></div>' } },
},
});
await new Promise((r) => setTimeout(r, 50));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(wrapper.vm as any).editorOpen = true;
await wrapper.vm.$nextTick();
// defaultEditor: ширины 100,200 → ступень 2 = 101300
const cell = wrapper.find('[data-testid="editor-range-2"]');
expect(cell.exists()).toBe(true);
expect(cell.text()).toContain('101300');
});
it('предпросмотр в редакторе пересчитывается при изменении ширины', async () => {
const wrapper = mount(AdminPricingTiersView, {
global: {
plugins: [vuetify],
stubs: { VDialog: { template: '<div><slot /></div>' } },
},
});
await new Promise((r) => setTimeout(r, 50));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
vm.editorOpen = true;
vm.editor[0].leads_in_tier = 250; // была 100 → ступень 2 сдвигается на 251–450
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="editor-range-2"]').text()).toContain('251450');
});
});
describe('AdminPricingTiersView error handling (Sprint 1 G1)', () => {
afterEach(() => {
vi.clearAllMocks();
-27
View File
@@ -4,10 +4,6 @@ import { createVuetify } from 'vuetify';
import DealsTable from '../../resources/js/components/deals/DealsTable.vue';
import type { MockDeal } from '../../resources/js/composables/mockDeals';
// ResponsiveTable показывает таблицу только на десктопе (>=1280); эти тесты
// проверяют табличный слой — фиксируем десктоп-ширину.
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1440 });
const vuetify = createVuetify();
const sampleDeals: MockDeal[] = [
@@ -132,29 +128,6 @@ describe('DealsTable', () => {
expect(links[1].props('to')).toMatchObject({ name: 'autopodbor', query: { competitor: '3', project: '5' } });
});
it('«Конкурент» — только бренд до первой запятой, без описания', () => {
const deal: MockDeal = {
id: 12,
name: '79000000002',
phone: '79000000002',
statusSlug: 'new',
project: 'X',
manager: { initials: '—', name: '—' },
cost: 0,
receivedMinutesAgo: 1,
competitorId: 4,
competitorName: 'AutoMoney, микрокредитная компания',
sourceIdentifier: '79135396546',
sourceSignalType: 'call',
sourceProjectId: 5,
sourceIsActive: true,
sourceExtraCount: 0,
};
const w = mountTable([deal]);
expect(w.text()).toContain('AutoMoney');
expect(w.text()).not.toContain('микрокредитная');
});
it('показывает « · +N» при нескольких источниках проекта', () => {
const deal: MockDeal = {
id: 11,
@@ -23,11 +23,6 @@ describe('ResponsiveTable', () => {
expect(w.find('[data-testid="as-card"]').exists()).toBe(true);
expect(w.find('[data-testid="as-table"]').exists()).toBe(false);
});
it('на планшете-портрете (768) показывает слот card, а не обрезанную таблицу', () => {
const w = mountAt(768);
expect(w.find('[data-testid="as-card"]').exists()).toBe(true);
expect(w.find('[data-testid="as-table"]').exists()).toBe(false);
});
it('на десктопе (>=1280) показывает слот table', () => {
const w = mountAt(1440);
expect(w.find('[data-testid="as-table"]').exists()).toBe(true);
@@ -7,10 +7,6 @@ import type { BillingTransaction, TransactionsPage } from '../../resources/js/ap
vi.mock('../../resources/js/api/billing');
// ResponsiveTable показывает таблицу только на десктопе (>=1280); эти тесты
// проверяют табличный слой — фиксируем десктоп-ширину.
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1440 });
const vuetify = createVuetify();
function txn(over: Partial<BillingTransaction> = {}): BillingTransaction {
-42
View File
@@ -1,42 +0,0 @@
import { describe, expect, it } from 'vitest';
import { tierRangeText } from '../../resources/js/utils/pricingTiers';
/**
* Единый источник истины для «ширина ступени → накопительный диапазон».
* Числа — ровно те, что клиент видит в кабинете (боевая сетка 08.07.2026):
* ширины 250/500/1000/2500/5000/10000/∞ → 1250, 251750, …, 19251+.
*/
const tiers = [
{ leads_in_tier: 250 },
{ leads_in_tier: 500 },
{ leads_in_tier: 1000 },
{ leads_in_tier: 2500 },
{ leads_in_tier: 5000 },
{ leads_in_tier: 10000 },
{ leads_in_tier: null },
];
describe('tierRangeText', () => {
it('первая ступень начинается с 1', () => {
expect(tierRangeText(tiers, 0)).toBe('1250');
});
it('вторая ступень — накопительный диапазон, а не «сама ширина»', () => {
expect(tierRangeText(tiers, 1)).toBe('251750');
});
it('третья ступень продолжает накопление', () => {
expect(tierRangeText(tiers, 2)).toBe('7511750');
});
it('шестая ступень', () => {
// 250+500+1000+2500 = 4250 → пятая 42519250 → шестая 925119250
expect(tierRangeText(tiers, 5)).toBe('925119250');
});
it('последняя ступень (leads_in_tier=null) — открытый диапазон со знаком +', () => {
// 250+500+1000+2500+5000+10000 = 19250 → tier 7 стартует с 19251
expect(tierRangeText(tiers, 6)).toBe('19251+');
});
});
-114
View File
@@ -1,114 +0,0 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────────────
# Лидерра — ЗАЩИЩЁННЫЙ выкат фронт-сборки портала (public/build).
#
# ЗАЧЕМ. Портал выкатывается ПОЛНОЙ заменой папки public/build. Если собрать
# из УСТАРЕВШЕЙ ветки (где нет чужих свежих фич) и залить — чужая работа
# МОЛЧА затирается. Реальный инцидент 08.07.2026: выкат Яндекс.Метрики,
# собранный из ветки без колонок «Сделок», снёс колонки «Конкурент/Источник».
#
# ЧТО ДЕЛАЕТ. Перед подменой сверяет: новая сборка НЕ должна ПОТЕРЯТЬ ни одной
# критичной фичи, которая есть в живой сборке. Потеряла — ОТКАЗ, боевой не тронут.
# Осознанный откат (когда фичу убираем намеренно) — только с FORCE=1.
#
# ВАЖНО. Маркеры ищем ТОЛЬКО в чанках, на которые ссылается manifest.json
# (активная сборка), а не по всей assets/ — там копятся «осколки» старых
# overlay-выкатов, из-за которых наивная проверка промахивалась.
#
# ИСПОЛЬЗОВАНИЕ (с dev-машины):
# tar czf build.tgz -C app/public/build .
# scp build.tgz liderra:/tmp/build.tgz
# ssh liderra 'sudo bash /var/www/liderra/deploy-build.sh /tmp/build.tgz "ветка/sha/что выкатываем"'
# # только проверить, не выкатывая:
# ssh liderra 'sudo DRY_RUN=1 bash /var/www/liderra/deploy-build.sh /tmp/build.tgz "проверка"'
# # осознанный откат/удаление фичи:
# ssh liderra 'sudo FORCE=1 bash /var/www/liderra/deploy-build.sh /tmp/build.tgz "rollback: ..."'
#
# Откат в любой момент: mv build.bak-guard-<ts> build (см. вывод скрипта).
# ─────────────────────────────────────────────────────────────────────────────
set -euo pipefail
TGZ="${1:?укажи путь к tar.gz со сборкой (содержимое папки build)}"
INFO="${2:-}"
FORCE="${FORCE:-}"
DRY_RUN="${DRY_RUN:-}"
BASE=/var/www/liderra/app/public
LIVE="$BASE/build"
# Критичные маркеры фич: если строка есть в ЖИВОЙ сборке — обязана быть и в новой.
# ➕ Добавляй строку сюда, когда выкатываешь новую пользовательскую фичу,
# чтобы её тоже нельзя было случайно затереть следующим выкатом.
MARKERS=(
"Конкурент" # колонки «Сделок»: Конкурент / Источник
"mc.yandex" # Яндекс.Метрика (счётчик + Вебвизор)
"ym-hide-content" # маскировка ПДн лидов для Метрики (152-ФЗ) — критично
"deal-card" # мобильные/планшетные карточки сделок
"autopodbor" # автоподбор конкурентов
)
echo "▶ Защищённый выкат сборки портала. Инфо: ${INFO:-}"
[ -f "$TGZ" ] || { echo "❌ Нет файла: $TGZ"; exit 2; }
STAGING="$BASE/.build-staging.$$"
rm -rf "$STAGING"; mkdir -p "$STAGING"
tar xzf "$TGZ" -C "$STAGING"
[ -f "$STAGING/manifest.json" ] || { echo "❌ В сборке нет manifest.json — это не билд портала."; rm -rf "$STAGING"; exit 2; }
# Список АКТИВНЫХ чанков (js/css) по manifest — без orphan-осколков прошлых выкатов.
active_files() { # $1 = dir сборки → печатает полные пути к активным чанкам
local dir="$1"
grep -oE 'assets/[A-Za-z0-9_.-]+\.(js|css)' "$dir/manifest.json" | sort -u | sed "s|^|$dir/|"
}
mapfile -t LIVE_FILES < <(active_files "$LIVE" 2>/dev/null || true)
mapfile -t NEW_FILES < <(active_files "$STAGING" 2>/dev/null || true)
hits() { # $1 = маркер, далее список файлов → сколько файлов содержат маркер
local m="$1"; shift
[ "$#" -eq 0 ] && { echo 0; return 0; }
# grep возвращает 1 при «не найдено»; при pipefail+set -e это рвёт скрипт —
# поэтому глушим код через "|| true", а число берём из вывода wc.
local c
c=$(grep -lF -- "$m" "$@" 2>/dev/null | wc -l) || true
echo "${c:-0}"
}
# ── ГЛАВНАЯ ЗАЩИТА: новая сборка не должна терять фичи живой ──────────────────
missing=()
for m in "${MARKERS[@]}"; do
live_n=$(hits "$m" "${LIVE_FILES[@]}")
new_n=$(hits "$m" "${NEW_FILES[@]}")
if [ "$live_n" -gt 0 ] && [ "$new_n" -eq 0 ]; then
missing+=("$m")
fi
done
if [ "${#missing[@]}" -gt 0 ] && [ "$FORCE" != "1" ]; then
echo ""
echo "🛑 ОТКАЗ: новая сборка ТЕРЯЕТ фичи, которые есть на боевом:"
printf ' • %s\n' "${missing[@]}"
echo ""
echo " Похоже, собрано из УСТАРЕВШЕЙ ветки (без чужой работы)."
echo " Пересобери из ветки-надмножества — где есть ВСЁ живое."
echo " Если это ОСОЗНАННЫЙ откат/удаление фичи — повтори с FORCE=1."
rm -rf "$STAGING"
exit 3
fi
if [ "${#missing[@]}" -gt 0 ]; then
echo "⚠ FORCE=1: осознанно теряем фичи (${missing[*]}) — по прямому указанию."
fi
if [ "$DRY_RUN" = "1" ]; then
echo "✅ DRY_RUN: проверка пройдена, все живые фичи на месте. Выкат НЕ выполнялся."
rm -rf "$STAGING"
exit 0
fi
# ── Подмена с бэкапом и отметкой о выкате ────────────────────────────────────
ts="$(date -u +%Y%m%d-%H%M%S)"
mv "$LIVE" "$BASE/build.bak-guard-$ts"
mv "$STAGING" "$LIVE"
appjs=$(grep -oE 'assets/app-[a-zA-Z0-9_-]+\.js' "$LIVE/manifest.json" | head -1)
printf '{"deployed_at":"%s","info":"%s","bundle":"%s","guard_ok":true}\n' \
"$ts" "${INFO//\"/}" "$appjs" > "$LIVE/DEPLOYED.json"
chown -R www-data:www-data "$LIVE"
echo "✅ Выкат ок. Бандл: $appjs. Бэкап прежней сборки: build.bak-guard-$ts"