feat обзвон: статус обзвона виден в карточке сделки, пометки касаний в списке
Повторная запись той же работы. Первую соседняя смена сняла с ветки откатом на один шаг назад, сделанным дважды подряд: первый откат убрал их собственный коммит про кошелёк, второй убрал мой. Содержимое сверено побайтно со снятым коммитом 015a10070 — все семнадцать файлов совпали до знака. Менеджер открывал карточку клиента и не знал, звонили ему уже или нет, чем кончился разговор, есть ли запись. Он набирал человека, которому робот звонил час назад и которому уже пообещали перезвонить завтра, — человек слышал от одной компании два несогласованных звонка. Теперь в карточке есть блок «Обзвон», а в строке списка сделок — пометки о том, что мы этого человека уже трогали. Главное в задаче — не показ, а замок. Восемь исходов обзвона живут В ОДНОМ столбце obzvon_number_results.outcome, и «не дозвонились» там такое же разрешённое значение, как «переведено». Замок в базе сторожит СПИСОК значений, а не их смысл, поэтому требование «двенадцать недозвонов после удачного разговора не затирают удачный разговор» базой не охранялось вовсе: правильное на вид действие с разрешённым значением давало запрещённый смысл, и ошибка выглядела бы безупречно. Заведена единственная дверь записи исхода, и правило спора вынесено в одно место — настройку. Что появилось: - config/obzvon.php — одно место раскладки восьми исходов на «про номер» и «про попытку», плюс два временных ответа на вопросы, которые владелец ещё не закрыл: где кончается «хоть секунда разговора» и сколько прошлых списков показывать в карточке. Переиграть каждый — поправить одно число; - RaskladkaIskhodov — единственный, кто знает разницу между недозвоном и семью остальными исходами. Три правила: недозвон не ложится поверх разговора никогда; разговор ложится поверх недозвона всегда; два разговора спорят временем, и опоздавший отчёт не перепишет свежий; - ItogPoNomeru — единственная дверь записи, с замком строки против гонки двух отчётов робота по одному номеру. В боевом коде её сегодня не зовёт никто: робот ещё не звонит. Дверь поставлена ДО первого пишущего — в этом её смысл; - SvodkaObzvonaVSdelke — читающая сторона. В карточку: одно верхнее значение, лента состоявшихся разговоров по каждому номеру, недозвоны свёрнуты в счётчик. В список сделок: пометки касаний одним запросом на модуль на всю страницу, а не запросом на строку. Слова для пометок подобраны нарочно другие. На экране сделок «Звонки» и «СМС» уже означают, ОТКУДА пришёл лид. Назвать пометку теми же словами значило бы дать человеку прочесть её задом наперёд, поэтому в колонке «Касания» стоят «Обзвон» и «Рассылка». Модулей два, а не четыре. Столбца deal_id нет ни у одной таблицы телеграма и нет у рекламы — связи со сделкой у них не существует, и придумывать её было бы враньём. Устройство под третий модуль открыто, и сторож числом запросов обязан его заметить. Схема базы не тронута: всё нужное было построено раньше. Сторожа показаны красными шестью вырезаниями: вырезанное правило Т84 роняет два сторожа сразу, «недозвон считается разговором» даёт девять строк ленты вместо двух, запрос на строку даёт 61 запрос вместо 16, слияние номеров роняет четыре сторожа, подмена слов пометок ловится сразу. Возврат каждого доказан слепком со снятием невидимых знаков конца строки. Прогоны: полный 5130 проверок, 5126 зелёных, 16099 утверждений, красных ноль. Обзвон 234 из 234. Статанализ ноль, deptrac ноль. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ use App\Models\LeadCharge;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierLeadCost;
|
||||
use App\Models\User;
|
||||
use App\Services\Obzvon\SvodkaObzvonaVSdelke;
|
||||
use App\Services\Pd\PdAuditLogger;
|
||||
use App\Services\SupplierResolver;
|
||||
use App\Support\SupplierProjectName;
|
||||
@@ -231,6 +232,14 @@ class DealController extends Controller
|
||||
});
|
||||
}
|
||||
|
||||
// З-2.4 Т67: пометки касаний — что МЫ уже делали с этим человеком, по
|
||||
// всей истории. 🪤 Один запрос на модуль на всю страницу, а не запрос на
|
||||
// строку: на этой грабле проект стоял дважды, и мерить надо числом
|
||||
// запросов на списке из пятидесяти сделок, а не секундомером на одной.
|
||||
$pometkiKasaniy = $dealIds !== []
|
||||
? app(SvodkaObzvonaVSdelke::class)->pometkiKasaniy($tenantId, $dealIds)
|
||||
: [];
|
||||
|
||||
$payload = [
|
||||
'deals' => $deals->map(fn (Deal $d) => [
|
||||
'id' => $d->id,
|
||||
@@ -255,6 +264,9 @@ class DealController extends Controller
|
||||
'project_sms_keyword' => $d->project?->sms_keyword,
|
||||
'project_sms_senders' => $d->project?->sms_senders,
|
||||
'cost_kopecks' => $costByDeal[$d->id] ?? null,
|
||||
// Пометки касаний (З-2.4 Т67): модуль → сколько раз мы трогали.
|
||||
// Пусто — значит не трогали ни разу.
|
||||
'touches' => (object) ($pometkiKasaniy[$d->id] ?? []),
|
||||
// Автоподбор (2026-07-08): конкурент + источник для колонок «Сделок».
|
||||
...(function () use ($d, $sourcesByProject) {
|
||||
$group = $sourcesByProject[$d->project_id] ?? collect();
|
||||
@@ -343,7 +355,14 @@ class DealController extends Controller
|
||||
ip: $request->ip(),
|
||||
);
|
||||
|
||||
// З-2.4 Р112: блок «Обзвон» карточки — одно верхнее значение (исход
|
||||
// последнего СОСТОЯВШЕГОСЯ разговора) + лента разговоров + свёрнутые
|
||||
// недозвоны. 🔴 Читающая сводка: статуса сделки не касается вовсе, у
|
||||
// сделки своя воронка из 14 значений, у обзвона своя.
|
||||
$obzvon = app(SvodkaObzvonaVSdelke::class)->dlyaKartochki($tenantId, $deal->id);
|
||||
|
||||
return response()->json([
|
||||
'obzvon' => $obzvon,
|
||||
'deal' => [
|
||||
'id' => $deal->id,
|
||||
'tenant_id' => $deal->tenant_id,
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Obzvon;
|
||||
|
||||
use App\Models\ObzvonNumberResult;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* ЕДИНСТВЕННАЯ дверь, через которую исход попадает в верхнюю строку карточки.
|
||||
*
|
||||
* 🔴 ЗАЧЕМ ДВЕРЬ, А НЕ ПРОСТО `$itog->update(['outcome' => ...])`.
|
||||
* Восемь исходов лежат в одном столбце, и `no_answer` — такое же разрешённое
|
||||
* значение, как `transferred`. База примет любое. Значит требование Т84
|
||||
* («недозвон не затирает удачный разговор») можно нарушить действием, которое
|
||||
* выглядит совершенно правильным. Пока запись рассыпана по коду, каждое новое
|
||||
* место — новый шанс нарушить. Здесь место одно.
|
||||
*
|
||||
* 🔴 ЧЕСТНО О ЗОВУЩИХ (замерено 06.08.2026): в боевом коде портала эту дверь
|
||||
* не зовёт НИКТО, потому что робот ещё не звонит — писать итог по номеру
|
||||
* сегодня некому. Дверь поставлена ДО того, как появится первый пишущий, — это
|
||||
* и есть смысл двери. Зовущие сегодня: сторожа
|
||||
* `tests/Feature/Obzvon/ObzvonItogPoNomeruTest.php`.
|
||||
*
|
||||
* Кто решает, перебивает ли новый исход прежний, — RaskladkaIskhodov, и только
|
||||
* он: правило живёт в одном месте, дверь его лишь исполняет.
|
||||
*/
|
||||
final class ItogPoNomeru
|
||||
{
|
||||
/**
|
||||
* Записать исход по номеру.
|
||||
*
|
||||
* Возвращает `true`, если верхняя строка изменилась, и `false`, если
|
||||
* запись отбита правилом Т84 (пришёл недозвон, а разговор уже состоялся)
|
||||
* или опоздавшим временем.
|
||||
*
|
||||
* 🪤 Строка блокируется на время решения (`lockForUpdate`): два отчёта
|
||||
* робота по одному номеру приезжают параллельно, и «прочитал — решил —
|
||||
* записал» без замка даёт гонку, в которой недозвон всё-таки затрёт
|
||||
* разговор. Замок стоит внутри транзакции, потому что вне транзакции
|
||||
* PostgreSQL снимает его сразу же.
|
||||
*
|
||||
* @param string $outcome один из восьми (ObzvonNumberResult::OUTCOMES)
|
||||
* @param Carbon|null $kogda когда исход случился; null — сейчас
|
||||
* @param Carbon|null $callbackAt время перезвона (только для `callback`)
|
||||
*/
|
||||
public function zapisat(
|
||||
ObzvonNumberResult $itog,
|
||||
string $outcome,
|
||||
?Carbon $kogda = null,
|
||||
?Carbon $callbackAt = null,
|
||||
): bool {
|
||||
RaskladkaIskhodov::proverit($outcome);
|
||||
|
||||
$kogda ??= Carbon::now();
|
||||
|
||||
return (bool) DB::transaction(function () use ($itog, $outcome, $kogda, $callbackAt) {
|
||||
/** @var ObzvonNumberResult|null $svezhiy */
|
||||
$svezhiy = ObzvonNumberResult::query()
|
||||
->whereKey($itog->getKey())
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($svezhiy === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$perebivaet = RaskladkaIskhodov::perebivaet(
|
||||
tekushchiy: $svezhiy->outcome,
|
||||
novyy: $outcome,
|
||||
tekushcheeVremya: $svezhiy->outcome_at?->getTimestamp(),
|
||||
novoeVremya: $kogda->getTimestamp(),
|
||||
);
|
||||
|
||||
if (! $perebivaet) {
|
||||
// 🔴 Отбито — и это НЕ ошибка, а штатный ход: недозвон после
|
||||
// разговора обязан пройти бесследно для верхней строки.
|
||||
$itog->setRawAttributes($svezhiy->getAttributes(), true);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$svezhiy->outcome = $outcome;
|
||||
$svezhiy->outcome_at = $kogda;
|
||||
|
||||
// Время перезвона живёт только у исхода «перезвонить»: оставить его
|
||||
// от прошлого исхода — значит показать менеджеру срок, которого уже
|
||||
// никто не обещал.
|
||||
$svezhiy->callback_at = $outcome === ObzvonNumberResult::OUTCOME_CALLBACK
|
||||
? $callbackAt
|
||||
: null;
|
||||
|
||||
$svezhiy->save();
|
||||
|
||||
$itog->setRawAttributes($svezhiy->getAttributes(), true);
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Отметить состоявшуюся попытку набора: счётчик попыток и время последнего
|
||||
* звонка. Верхней строки НЕ касается — попытка и итог это разные вещи.
|
||||
*/
|
||||
public function otmetitPopytku(ObzvonNumberResult $itog, ?Carbon $kogda = null): void
|
||||
{
|
||||
$kogda ??= Carbon::now();
|
||||
|
||||
DB::transaction(function () use ($itog, $kogda) {
|
||||
/** @var ObzvonNumberResult|null $svezhiy */
|
||||
$svezhiy = ObzvonNumberResult::query()
|
||||
->whereKey($itog->getKey())
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($svezhiy === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$svezhiy->attempts_count = $svezhiy->attempts_count + 1;
|
||||
if ($svezhiy->last_call_at === null || $svezhiy->last_call_at->lt($kogda)) {
|
||||
$svezhiy->last_call_at = $kogda;
|
||||
}
|
||||
$svezhiy->save();
|
||||
|
||||
$itog->setRawAttributes($svezhiy->getAttributes(), true);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Obzvon;
|
||||
|
||||
use App\Models\ObzvonNumberResult;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Единственный, кто знает, чем недозвон отличается от остальных семи исходов.
|
||||
*
|
||||
* 🔴 ОТ КАКОГО ВРЕДА. `no_answer` — одно из ВОСЬМИ разрешённых значений того же
|
||||
* самого столбца `obzvon_number_results.outcome`. CHECK-замок в базе его
|
||||
* пропустит, потому что замок сторожит СПИСОК значений, а не их смысл. Значит
|
||||
* требование Т84 («двенадцать недозвонов после удачного разговора не затирают
|
||||
* удачный разговор») базой не охраняется вовсе: правильное на вид действие с
|
||||
* разрешённым значением даёт запрещённый смысл, и ошибка выглядит безупречно.
|
||||
*
|
||||
* Здесь эта разница названа один раз, читается из `config/obzvon.php` и больше
|
||||
* нигде не повторяется. Переиграть раскладку — переставить слаг из списка в
|
||||
* список в настройке.
|
||||
*
|
||||
* 🪤 Класс НЕ пишет в базу и НЕ читает её. Он отвечает на вопросы о смысле, а
|
||||
* записью занимается ItogPoNomeru — единственная дверь.
|
||||
*/
|
||||
final class RaskladkaIskhodov
|
||||
{
|
||||
/**
|
||||
* Исход «про номер» — итог самого номера. Верхнюю строку карточки берёт
|
||||
* последний по времени из них, и в ленту разговоров он попадает.
|
||||
*/
|
||||
public static function proNomer(string $outcome): bool
|
||||
{
|
||||
return in_array($outcome, self::spisok('pro_nomer'), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Исход «про попытку» — итог одной попытки набора. Верхнюю строку не
|
||||
* меняет, в ленту не попадает, сворачивается в счётчик недозвонов.
|
||||
*/
|
||||
public static function proPopytku(string $outcome): bool
|
||||
{
|
||||
return in_array($outcome, self::spisok('pro_popytku'), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 ГЛАВНЫЙ ВОПРОС ЗАДАЧИ: перебьёт ли новый исход тот, что уже стоит.
|
||||
*
|
||||
* Решение принимается по СМЫСЛУ и по ВРЕМЕНИ, а не по порядку прихода —
|
||||
* иначе недозвон, приехавший позже удачного разговора и со свежей меткой
|
||||
* времени, прошёл бы насквозь.
|
||||
*
|
||||
* Три правила, и каждое отвечает своему случаю:
|
||||
*
|
||||
* 1. исход «про попытку» НИКОГДА не ложится поверх исхода «про номер» —
|
||||
* это и есть Т84. Поверх пустоты или другого недозвона ложится: иначе
|
||||
* двенадцать попыток дадут пустую карточку вместо «не дозвонились»;
|
||||
* 2. исход «про номер» ложится поверх недозвона всегда — состоявшийся
|
||||
* разговор старше по смыслу, каким бы ни было время;
|
||||
* 3. два исхода «про номер» между собой спорят ВРЕМЕНЕМ: верх берёт
|
||||
* последний по `outcome_at`. Отчёт, приехавший с опозданием, не
|
||||
* перепишет более свежий. Равное время — новый побеждает: в пределах
|
||||
* одной секунды порядок прихода единственное, что у нас есть.
|
||||
*
|
||||
* @param string|null $tekushchiy исход, который стоит сейчас (null — пусто)
|
||||
* @param int|null $tekushcheeVremya метка времени текущего исхода (unix-секунды)
|
||||
* @param int|null $novoeVremya метка времени нового исхода (unix-секунды)
|
||||
*/
|
||||
public static function perebivaet(
|
||||
?string $tekushchiy,
|
||||
string $novyy,
|
||||
?int $tekushcheeVremya = null,
|
||||
?int $novoeVremya = null,
|
||||
): bool {
|
||||
self::proverit($novyy);
|
||||
|
||||
if ($tekushchiy === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
self::proverit($tekushchiy);
|
||||
|
||||
// Правило 1 — то самое Т84.
|
||||
if (self::proPopytku($novyy) && self::proNomer($tekushchiy)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Правило 2.
|
||||
if (self::proNomer($novyy) && self::proPopytku($tekushchiy)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Правило 3 — спор по времени внутри одного рода.
|
||||
if ($tekushcheeVremya === null || $novoeVremya === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $novoeVremya >= $tekushcheeVremya;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка, что слаг вообще из восьми. Список — канон в модели, а не здесь:
|
||||
* два списка восьми значений однажды разъедутся.
|
||||
*/
|
||||
public static function proverit(string $outcome): void
|
||||
{
|
||||
if (! in_array($outcome, ObzvonNumberResult::OUTCOMES, true)) {
|
||||
throw new InvalidArgumentException('Неизвестный исход обзвона: '.$outcome);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 Сторож самой раскладки: настройка обязана покрывать ровно восемь
|
||||
* исходов, каждый ровно один раз. Разъедется настройка с моделью — исход
|
||||
* молча провалится мимо обоих списков, и «недозвон» окажется в ленте.
|
||||
*
|
||||
* @return list<string> исходы, которых настройка не покрыла
|
||||
*/
|
||||
public static function nepokrytye(): array
|
||||
{
|
||||
$vse = array_merge(self::spisok('pro_nomer'), self::spisok('pro_popytku'));
|
||||
|
||||
return array_values(array_diff(ObzvonNumberResult::OUTCOMES, $vse));
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private static function spisok(string $rod): array
|
||||
{
|
||||
/** @var list<string> $znacheniya */
|
||||
$znacheniya = (array) config('obzvon.raskladka.'.$rod, []);
|
||||
|
||||
return array_values(array_filter($znacheniya, 'is_string'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Obzvon;
|
||||
|
||||
use App\Models\ClientSmsMessage;
|
||||
use App\Models\ObzvonCall;
|
||||
use App\Models\ObzvonNumberResult;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Читающая сторона З-2.4: что менеджер видит про обзвон, открыв сделку, и что
|
||||
* он видит в строке списка сделок.
|
||||
*
|
||||
* 🔴 ОТ КАКОГО ВРЕДА. Менеджер открывает карточку и не знает, звонили человеку
|
||||
* или нет. Он набирает того, кому робот звонил час назад и кому уже обещали
|
||||
* перезвонить завтра, — человек слышит от одной компании два несогласованных
|
||||
* звонка. Ровно от этого здесь всё.
|
||||
*
|
||||
* 🔴 ЧЕГО ЗДЕСЬ НАРОЧНО НЕТ. Ни одной записи в базу. Статус обзвона и статус
|
||||
* сделки — две разные воронки (у сделки своя, из 14 значений), и читающая
|
||||
* сводка не вправе тронуть ни одну.
|
||||
*
|
||||
* 🪤 Дважды на этом проекте ловили одно и то же: вопрос к базе внутри перебора
|
||||
* строк. Пометки касаний собираются ОДНИМ запросом на модуль на всю страницу —
|
||||
* не по запросу на сделку. Сторож считает именно запросы, а не секунды.
|
||||
*/
|
||||
final class SvodkaObzvonaVSdelke
|
||||
{
|
||||
/** Запись разговора лежит и доступна. */
|
||||
public const ZAPIS_EST = 'est';
|
||||
|
||||
/** Запись была и стёрта по сроку хранения — это НЕ «записи не было». */
|
||||
public const ZAPIS_STYORTA = 'styorta';
|
||||
|
||||
/** Записи не было вовсе. */
|
||||
public const ZAPIS_NE_BYLO = 'ne_bylo';
|
||||
|
||||
/**
|
||||
* Блок «Обзвон» карточки сделки: одно верхнее значение + лента разговоров
|
||||
* по каждому номеру + свёрнутые недозвоны.
|
||||
*
|
||||
* Два запроса на сделку, оба по указателям `*_deal_idx`.
|
||||
*
|
||||
* @return array{itog: array<string, mixed>|null, nomera: list<array<string, mixed>>, kampaniy_skryto: int}
|
||||
*/
|
||||
public function dlyaKartochki(int $tenantId, int $dealId): array
|
||||
{
|
||||
/** @var array{0: Collection<int, ObzvonNumberResult>, 1: Collection<int, ObzvonCall>} $dannye */
|
||||
$dannye = DB::transaction(function () use ($tenantId, $dealId) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
$itogi = ObzvonNumberResult::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('deal_id', $dealId)
|
||||
->get();
|
||||
|
||||
$zvonki = ObzvonCall::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('deal_id', $dealId)
|
||||
->orderBy('started_at')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
return [$itogi, $zvonki];
|
||||
});
|
||||
|
||||
[$itogi, $zvonki] = $dannye;
|
||||
|
||||
if ($itogi->isEmpty() && $zvonki->isEmpty()) {
|
||||
// Сделка без обзвона выглядит ровно как раньше: пустой блок, а не
|
||||
// пустая рамка с заголовком.
|
||||
return ['itog' => null, 'nomera' => [], 'kampaniy_skryto' => 0];
|
||||
}
|
||||
|
||||
$gruppy = $this->razlozhitPoNomeram($itogi, $zvonki);
|
||||
|
||||
[$gruppy, $skryto] = $this->obrezatPoKampaniyam($gruppy);
|
||||
|
||||
return [
|
||||
'itog' => $this->verkhnyayaStroka($gruppy),
|
||||
'nomera' => $gruppy,
|
||||
'kampaniy_skryto' => $skryto,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Пометки касаний для списка сделок: по какой сделке мы человека уже
|
||||
* трогали и сколько раз, ПО ВСЕЙ истории, а не по последнему действию.
|
||||
*
|
||||
* 🔴 Слова подобраны так, чтобы не столкнуться с колонкой «Источник»: там
|
||||
* «Звонки» и «СМС» означают, ОТКУДА пришёл лид. Здесь наоборот — что мы
|
||||
* сделали сами, и называется это «Обзвон» и «Рассылка».
|
||||
*
|
||||
* 🔴 Ровно два модуля, а не четыре (замерено 06.08.2026): столбца `deal_id`
|
||||
* нет ни у одной таблицы телеграма (`client_tg_*`), ни у рекламы. Связи со
|
||||
* сделкой у них не существует, и придумать её здесь было бы враньём.
|
||||
* Устройство под третий модуль готово — один запрос и одно слово.
|
||||
*
|
||||
* @param list<int> $dealIds
|
||||
* @return array<int, array<string, int>> сделка → модуль → сколько касаний
|
||||
*/
|
||||
public function pometkiKasaniy(int $tenantId, array $dealIds): array
|
||||
{
|
||||
if ($dealIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** @var array{0: Collection<int, int>, 1: Collection<int, int>} $schetchiki */
|
||||
$schetchiki = DB::transaction(function () use ($tenantId, $dealIds) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
$obzvon = ObzvonCall::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('deal_id', $dealIds)
|
||||
->groupBy('deal_id')
|
||||
->selectRaw('deal_id, count(*) as skolko')
|
||||
->pluck('skolko', 'deal_id');
|
||||
|
||||
$sms = ClientSmsMessage::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('deal_id', $dealIds)
|
||||
->groupBy('deal_id')
|
||||
->selectRaw('deal_id, count(*) as skolko')
|
||||
->pluck('skolko', 'deal_id');
|
||||
|
||||
return [$obzvon, $sms];
|
||||
});
|
||||
|
||||
[$obzvon, $sms] = $schetchiki;
|
||||
|
||||
$itog = [];
|
||||
foreach ($dealIds as $dealId) {
|
||||
$pometki = [];
|
||||
if ((int) ($obzvon[$dealId] ?? 0) > 0) {
|
||||
$pometki['obzvon'] = (int) $obzvon[$dealId];
|
||||
}
|
||||
if ((int) ($sms[$dealId] ?? 0) > 0) {
|
||||
$pometki['sms'] = (int) $sms[$dealId];
|
||||
}
|
||||
$itog[$dealId] = $pometki;
|
||||
}
|
||||
|
||||
return $itog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Разговор ли это. Одно место, где живёт ответ на вопрос владельца «где
|
||||
* кончается хоть секунда разговора» — порог берётся из настройки.
|
||||
*/
|
||||
public function etoRazgovor(ObzvonCall $zvonok): bool
|
||||
{
|
||||
$porog = (int) config('obzvon.kartochka.minimalnaya_dlitelnost_sec', 1);
|
||||
|
||||
return $zvonok->answered && $zvonok->leg1_duration_sec >= $porog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Раскладка звонков по номерам. Ничего не сливается молча: два номера или
|
||||
* две кампании дают ДВЕ записи, а звонки, у которых итога нет вовсе,
|
||||
* собираются в свою запись по телефону, а не подмешиваются к чужой.
|
||||
*
|
||||
* @param Collection<int, ObzvonNumberResult> $itogi
|
||||
* @param Collection<int, ObzvonCall> $zvonki
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function razlozhitPoNomeram(Collection $itogi, Collection $zvonki): array
|
||||
{
|
||||
$gruppy = [];
|
||||
|
||||
foreach ($itogi as $itog) {
|
||||
$gruppy['itog:'.$itog->id] = [
|
||||
'itog_id' => $itog->id,
|
||||
'phone' => $itog->phone,
|
||||
'campaign_id' => $itog->campaign_id,
|
||||
'outcome' => $itog->outcome,
|
||||
'outcome_at' => $itog->outcome_at?->toIso8601String(),
|
||||
'callback_at' => $itog->callback_at?->toIso8601String(),
|
||||
'popytok_vsego' => $itog->attempts_count,
|
||||
'last_call_at' => $itog->last_call_at?->toIso8601String(),
|
||||
'nedozvonov' => 0,
|
||||
'razgovory' => [],
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($zvonki as $zvonok) {
|
||||
$klyuch = $zvonok->number_result_id !== null && isset($gruppy['itog:'.$zvonok->number_result_id])
|
||||
? 'itog:'.$zvonok->number_result_id
|
||||
: 'nomer:'.$zvonok->phone;
|
||||
|
||||
if (! isset($gruppy[$klyuch])) {
|
||||
// Звонок без итога. Итога нет — значит и верхней строки по
|
||||
// этому номеру нет; выдумывать её нельзя.
|
||||
$gruppy[$klyuch] = [
|
||||
'itog_id' => null,
|
||||
'phone' => $zvonok->phone,
|
||||
'campaign_id' => $zvonok->campaign_id,
|
||||
'outcome' => null,
|
||||
'outcome_at' => null,
|
||||
'callback_at' => null,
|
||||
'popytok_vsego' => 0,
|
||||
'last_call_at' => null,
|
||||
'nedozvonov' => 0,
|
||||
'razgovory' => [],
|
||||
];
|
||||
}
|
||||
|
||||
if ($this->etoRazgovor($zvonok)) {
|
||||
$gruppy[$klyuch]['razgovory'][] = [
|
||||
'id' => $zvonok->id,
|
||||
'started_at' => $zvonok->started_at->toIso8601String(),
|
||||
'dlitelnost_sec' => $zvonok->leg1_duration_sec,
|
||||
'perezvonil_sam' => $zvonok->direction === ObzvonCall::DIRECTION_INBOUND,
|
||||
'attempt_outcome' => $zvonok->attempt_outcome,
|
||||
'zapis' => $this->sostoyanieZapisi($zvonok),
|
||||
];
|
||||
} else {
|
||||
// 🔴 Недозвон в ленту НЕ попадает — сворачивается в счётчик.
|
||||
$gruppy[$klyuch]['nedozvonov']++;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($gruppy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Сколько прошлых кампаний показать (Р113: прошлый итог виден). Настройка
|
||||
* `null` — все; число N — N самых свежих кампаний.
|
||||
*
|
||||
* @param list<array<string, mixed>> $gruppy
|
||||
* @return array{0: list<array<string, mixed>>, 1: int}
|
||||
*/
|
||||
private function obrezatPoKampaniyam(array $gruppy): array
|
||||
{
|
||||
usort($gruppy, function (array $a, array $b): int {
|
||||
return $this->svezhest($b) <=> $this->svezhest($a);
|
||||
});
|
||||
|
||||
$predel = config('obzvon.kartochka.kampaniy_v_kartochke');
|
||||
if ($predel === null) {
|
||||
return [$gruppy, 0];
|
||||
}
|
||||
|
||||
$predel = max(1, (int) $predel);
|
||||
|
||||
$vidno = [];
|
||||
$kampanii = [];
|
||||
$skryto = 0;
|
||||
|
||||
foreach ($gruppy as $gruppa) {
|
||||
$kampaniya = $gruppa['campaign_id'];
|
||||
$klyuch = $kampaniya === null ? 'bez' : (string) $kampaniya;
|
||||
|
||||
if (! in_array($klyuch, $kampanii, true)) {
|
||||
if (count($kampanii) >= $predel) {
|
||||
$skryto++;
|
||||
|
||||
continue;
|
||||
}
|
||||
$kampanii[] = $klyuch;
|
||||
}
|
||||
|
||||
$vidno[] = $gruppa;
|
||||
}
|
||||
|
||||
return [$vidno, $skryto];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $gruppa */
|
||||
private function svezhest(array $gruppa): int
|
||||
{
|
||||
foreach (['outcome_at', 'last_call_at'] as $pole) {
|
||||
$znachenie = $gruppa[$pole];
|
||||
if (is_string($znachenie)) {
|
||||
return (int) strtotime($znachenie);
|
||||
}
|
||||
}
|
||||
|
||||
$razgovory = $gruppa['razgovory'];
|
||||
|
||||
return is_array($razgovory) && $razgovory !== []
|
||||
? (int) strtotime((string) $razgovory[count($razgovory) - 1]['started_at'])
|
||||
: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 Верхняя строка — исход ПОСЛЕДНЕГО состоявшегося разговора, и выбирает
|
||||
* её то же самое правило, что охраняет запись (RaskladkaIskhodov). Второго
|
||||
* места, где недозвон отличают от разговора, в портале нет: разъехаться
|
||||
* нечему.
|
||||
*
|
||||
* @param list<array<string, mixed>> $gruppy
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function verkhnyayaStroka(array $gruppy): ?array
|
||||
{
|
||||
// 🔴 Свёртка идёт СТРОГО от старого к новому — так же, как исходы
|
||||
// приходили бы во времени. Замерено 06.08.2026: при обратном порядке
|
||||
// правило Т84 на читающей стороне становится недостижимым (свежий
|
||||
// недозвон встаёт первым, и его перебивает правило 2 «разговор старше
|
||||
// недозвона»), и сторож перестаёт краснеть на вырезанном Т84 — то есть
|
||||
// перестаёт сторожить. Список `nomera` показывается в другом порядке,
|
||||
// свежим вверх, и это разные вопросы.
|
||||
$poVremeni = $gruppy;
|
||||
usort($poVremeni, fn (array $a, array $b): int => $this->svezhest($a) <=> $this->svezhest($b));
|
||||
|
||||
$luchshiy = null;
|
||||
|
||||
foreach ($poVremeni as $gruppa) {
|
||||
$outcome = $gruppa['outcome'];
|
||||
if (! is_string($outcome)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$vremya = is_string($gruppa['outcome_at']) ? (int) strtotime($gruppa['outcome_at']) : null;
|
||||
|
||||
if ($luchshiy === null) {
|
||||
$luchshiy = $gruppa;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$tekushchee = is_string($luchshiy['outcome_at']) ? (int) strtotime((string) $luchshiy['outcome_at']) : null;
|
||||
|
||||
if (RaskladkaIskhodov::perebivaet((string) $luchshiy['outcome'], $outcome, $tekushchee, $vremya)) {
|
||||
$luchshiy = $gruppa;
|
||||
}
|
||||
}
|
||||
|
||||
if ($luchshiy === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'outcome' => $luchshiy['outcome'],
|
||||
'outcome_at' => $luchshiy['outcome_at'],
|
||||
'callback_at' => $luchshiy['callback_at'],
|
||||
'phone' => $luchshiy['phone'],
|
||||
];
|
||||
}
|
||||
|
||||
/** Стёрто по сроку и «не было вовсе» — два разных ответа человеку (Т73). */
|
||||
private function sostoyanieZapisi(ObzvonCall $zvonok): string
|
||||
{
|
||||
if ($zvonok->zvukStyortPoSroku()) {
|
||||
return self::ZAPIS_STYORTA;
|
||||
}
|
||||
|
||||
return $zvonok->recording_path !== null ? self::ZAPIS_EST : self::ZAPIS_NE_BYLO;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Настройки обзвона — ОДНО место, где лежат решения, которые дороже всего
|
||||
* переигрывать рассыпанными по коду (З-2.4, решения владельца Р112/Р113).
|
||||
*
|
||||
* 🔴 Зачем файл вообще. Восемь исходов Т21/Т65 живут В ОДНОМ столбце
|
||||
* `obzvon_number_results.outcome`, и замок в базе пропускает любой из восьми.
|
||||
* Значит «недозвон верхнюю строку не меняет» (Т84) базой НЕ охраняется: тот,
|
||||
* кто запишет недозвон туда же, куда пишет остальные семь, затрёт удачный
|
||||
* разговор молча — столбец один, значение разрешено, база примет. Единственное
|
||||
* место, где эта разница вообще существует, — здесь.
|
||||
*
|
||||
* Кто это читает: App\Services\Obzvon\RaskladkaIskhodov (и только он).
|
||||
*/
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Раскладка восьми исходов на два рода (Р112)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| «Про номер» — исход самого номера. Верхнюю строку карточки берёт
|
||||
| ПОСЛЕДНИЙ ПО ВРЕМЕНИ из них, и каждый попадает в ленту разговоров.
|
||||
|
|
||||
| «Про попытку» — исход одной попытки набора. Верхнюю строку НЕ меняет,
|
||||
| в ленту не попадает, сворачивается в счётчик «не дозвонились: N раз».
|
||||
|
|
||||
| 🔴 Ровно одно исключение, и оно нарочное: когда состоявшегося разговора
|
||||
| НЕ БЫЛО НИ РАЗУ, верхняя строка обязана сказать «не дозвонились» — иначе
|
||||
| двенадцать попыток дадут пустую карточку. Поэтому исход «про попытку»
|
||||
| ставится как ЗАПАСНОЙ: пишется только поверх пустоты или самого себя и
|
||||
| никогда поверх исхода «про номер». Правило исполнено в одном месте —
|
||||
| App\Services\Obzvon\ItogPoNomeru::zapisat.
|
||||
|
|
||||
| Переиграть раскладку = переставить слаг из списка в список. Минута.
|
||||
| Восемь слагов — канон в App\Models\ObzvonNumberResult::OUTCOMES и в
|
||||
| CHECK-замке `chk_obzvon_number_results_outcome`; здесь только раскладка.
|
||||
|
|
||||
*/
|
||||
'raskladka' => [
|
||||
|
||||
'pro_nomer' => [
|
||||
'transferred', // переведено
|
||||
'transfer_failed', // переведено неуспешно
|
||||
'callback', // перезвонить, дата и время
|
||||
'email_sent', // письмо клиенту отправлено
|
||||
'not_target', // не целевой
|
||||
'refused', // отказ
|
||||
'aggression', // агрессия
|
||||
],
|
||||
|
||||
'pro_popytku' => [
|
||||
'no_answer', // недозвон
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Карточка сделки — два вопроса, которые владелец ещё НЕ закрыл (З-2.4)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| 🟡 Значения ниже — ВРЕМЕННЫЕ, поставлены помощником, чтобы код не решал
|
||||
| вопрос владельца молча. Переиграть каждый = поправить одно число здесь.
|
||||
|
|
||||
*/
|
||||
'kartochka' => [
|
||||
|
||||
/*
|
||||
* Где кончается «хоть секунда разговора» (Т84а).
|
||||
*
|
||||
* Разговор попадает в ленту, когда трубку сняли (`answered`) И первое
|
||||
* плечо длилось не меньше этого числа секунд. Длительность в базе —
|
||||
* целые секунды (`leg1_duration_sec INTEGER`), так что 0,4 секунды
|
||||
* физически приедет либо нулём, либо единицей — этим числом решается,
|
||||
* что с ней делать.
|
||||
*
|
||||
* 🟡 Временно 1: «ноль секунд — не разговор, одна секунда — разговор».
|
||||
* Владелец вправе поставить 3, 5 или 0.
|
||||
*/
|
||||
'minimalnaya_dlitelnost_sec' => 1,
|
||||
|
||||
/*
|
||||
* Сколько ПРОШЛЫХ кампаний показывать в карточке (Р113).
|
||||
*
|
||||
* Тот же номер новым списком заводит НОВЫЙ итог (замок
|
||||
* `UNIQUE (tenant_id, campaign_id, phone)`), прежний остаётся историей
|
||||
* и по Р113 в карточке виден.
|
||||
*
|
||||
* null — показывать все. Число N — показывать N самых свежих кампаний.
|
||||
*
|
||||
* 🟡 Временно null: прятать историю по своему усмотрению помощник не
|
||||
* вправе — Р113 требует, чтобы прошлое было ВИДНО. Владелец вправе
|
||||
* поставить 1.
|
||||
*/
|
||||
'kampaniy_v_kartochke' => null,
|
||||
],
|
||||
];
|
||||
@@ -179,6 +179,45 @@ export interface ApiDeal {
|
||||
source_project_id?: number | null;
|
||||
source_is_active?: boolean | null;
|
||||
source_extra_count?: number;
|
||||
// Пометки касаний (З-2.4 Т67): что МЫ делали с человеком и сколько раз, по
|
||||
// всей истории. Ключ — модуль ('obzvon' | 'sms'), значение — сколько раз.
|
||||
// 🔴 Не путать с `signal_type`: тот про то, ОТКУДА пришёл лид.
|
||||
touches?: Record<string, number>;
|
||||
}
|
||||
|
||||
/** Один состоявшийся разговор в ленте карточки (З-2.4 Т84а). */
|
||||
export interface ApiObzvonRazgovor {
|
||||
id: number;
|
||||
started_at: string;
|
||||
dlitelnost_sec: number;
|
||||
/** Человек перезвонил сам (входящий) — тоже строка ленты (Т55). */
|
||||
perezvonil_sam: boolean;
|
||||
attempt_outcome: string | null;
|
||||
/** 'est' | 'styorta' | 'ne_bylo' — стёртое по сроку это НЕ «не было» (Т73). */
|
||||
zapis: 'est' | 'styorta' | 'ne_bylo';
|
||||
}
|
||||
|
||||
/** Работа с ОДНИМ номером: свой итог, своя лента, свой счётчик недозвонов. */
|
||||
export interface ApiObzvonNomer {
|
||||
itog_id: number | null;
|
||||
phone: string;
|
||||
campaign_id: number | null;
|
||||
outcome: string | null;
|
||||
outcome_at: string | null;
|
||||
callback_at: string | null;
|
||||
popytok_vsego: number;
|
||||
last_call_at: string | null;
|
||||
/** Недозвоны свёрнуты в число — в ленту они не попадают (Т84). */
|
||||
nedozvonov: number;
|
||||
razgovory: ApiObzvonRazgovor[];
|
||||
}
|
||||
|
||||
export interface ApiObzvonBlok {
|
||||
/** Одно верхнее значение — исход последнего СОСТОЯВШЕГОСЯ разговора. */
|
||||
itog: { outcome: string; outcome_at: string | null; callback_at: string | null; phone: string } | null;
|
||||
nomera: ApiObzvonNomer[];
|
||||
/** Сколько прошлых кампаний скрыто настройкой (Р113). */
|
||||
kampaniy_skryto: number;
|
||||
}
|
||||
|
||||
export interface ApiDealEvent {
|
||||
@@ -197,6 +236,8 @@ export interface ApiDealDetail extends ApiDeal {
|
||||
export interface GetDealResponse {
|
||||
deal: ApiDealDetail;
|
||||
events: ApiDealEvent[];
|
||||
/** Блок «Обзвон» карточки (З-2.4). Может отсутствовать у старого ответа. */
|
||||
obzvon?: ApiObzvonBlok;
|
||||
}
|
||||
|
||||
export async function getDeal(id: number, tenantId: number): Promise<GetDealResponse> {
|
||||
|
||||
@@ -14,7 +14,9 @@ import { stripChannelPrefix } from '../../composables/projectName';
|
||||
import * as dealsApi from '../../api/deals';
|
||||
import { useLeadStatusesStore } from '../../stores/leadStatuses';
|
||||
import DealDetailHero from './DealDetailHero.vue';
|
||||
import DealDetailObzvon from './DealDetailObzvon.vue';
|
||||
import DealDetailTimeline from './DealDetailTimeline.vue';
|
||||
import type { ApiObzvonBlok } from '../../api/deals';
|
||||
|
||||
const leadStatusesStore = useLeadStatusesStore();
|
||||
|
||||
@@ -63,6 +65,9 @@ const projectSourceLabel = computed((): string => {
|
||||
const events = ref<DealEvent[]>([]);
|
||||
const eventsLoading = ref(false);
|
||||
const eventsFetchError = ref(false);
|
||||
// Блок «Обзвон» (З-2.4). Приезжает тем же запросом, что и события, — отдельного
|
||||
// похода на сервер карточка не делает.
|
||||
const obzvon = ref<ApiObzvonBlok | null>(null);
|
||||
|
||||
const commentDraft = ref<string>('');
|
||||
const commentSaving = ref(false);
|
||||
@@ -73,6 +78,7 @@ const commentToastText = ref('');
|
||||
async function loadEvents() {
|
||||
if (!props.deal || !props.tenantId) {
|
||||
events.value = [];
|
||||
obzvon.value = null;
|
||||
commentDraft.value = '';
|
||||
return;
|
||||
}
|
||||
@@ -84,9 +90,11 @@ async function loadEvents() {
|
||||
mapApiDealEvent(e, new Date(), (slug) => leadStatusesStore.findBySlug(slug)?.nameRu ?? slug),
|
||||
);
|
||||
commentDraft.value = res.deal.comment ?? '';
|
||||
obzvon.value = res.obzvon ?? null;
|
||||
} catch {
|
||||
eventsFetchError.value = true;
|
||||
events.value = [];
|
||||
obzvon.value = null;
|
||||
commentDraft.value = '';
|
||||
} finally {
|
||||
eventsLoading.value = false;
|
||||
@@ -135,6 +143,7 @@ defineExpose({
|
||||
events,
|
||||
eventsLoading,
|
||||
eventsFetchError,
|
||||
obzvon,
|
||||
loadEvents,
|
||||
commentDraft,
|
||||
commentSaving,
|
||||
@@ -213,6 +222,11 @@ defineExpose({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Обзвон — ВЫШЕ «Активности»: это то, ради чего менеджер открыл
|
||||
карточку, а журнал действий — справка. Почему не строками в самой
|
||||
«Активности» — разобрано в шапке DealDetailObzvon.vue. -->
|
||||
<DealDetailObzvon :blok="obzvon" />
|
||||
|
||||
<DealDetailTimeline :events="events" :events-fetch-error="eventsFetchError" />
|
||||
|
||||
<v-snackbar
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Блок «Обзвон» в карточке сделки (З-2.4, решение владельца Р112).
|
||||
*
|
||||
* 🔴 ОТ КАКОГО ВРЕДА. Менеджер открывает карточку и не знает, звонили человеку
|
||||
* или нет. Он набирает того, кому робот звонил час назад и кому уже пообещали
|
||||
* перезвонить завтра, — человек слышит от одной компании два несогласованных
|
||||
* звонка.
|
||||
*
|
||||
* 🔴 ПОЧЕМУ ОТДЕЛЬНЫЙ БЛОК, А НЕ СТРОКИ В СУЩЕСТВУЮЩЕЙ ЛЕНТЕ «АКТИВНОСТЬ».
|
||||
* Соблазн был: лента там уже есть и она настоящая. Но она про другое —
|
||||
* это журнал действий ЛЮДЕЙ по сделке (`activity_log`: кто сменил статус, кто
|
||||
* написал комментарий), у каждой строки есть автор и относительное время
|
||||
* «12 мин назад». У обзвона три вещи, которых в такой ленте быть не может:
|
||||
* 1. ОДНА верхняя строка — исход последнего состоявшегося разговора. У
|
||||
* журнала действий верхней строки нет и быть не должно;
|
||||
* 2. свёртка недозвонов в счётчик. Двенадцать попыток обязаны дать одну
|
||||
* строку «не дозвонились: 12 раз», а журнал действий по устройству
|
||||
* показывает каждое событие своей строкой — свернуть там нечего и нечем;
|
||||
* 3. разбивка по номерам и кампаниям. Два номера на сделке дают две ленты, и
|
||||
* слить их в общий поток значит соврать, что говорили с одним человеком.
|
||||
* Подмешать это в журнал действий можно только сломав его же правила. Поэтому
|
||||
* блок свой, и стоит он ВЫШЕ «Активности»: обзвон — то, ради чего менеджер
|
||||
* открыл карточку, а журнал действий — справка.
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
import type { ApiObzvonBlok, ApiObzvonNomer } from '../../api/deals';
|
||||
import { dlitelnostSlovami, obzvonIskhodLabel, obzvonVremya, zapisSlovami } from '../../composables/obzvonIskhody';
|
||||
|
||||
const props = defineProps<{
|
||||
blok: ApiObzvonBlok | null;
|
||||
}>();
|
||||
|
||||
// Пусто — блока нет вовсе. Сделка без обзвона выглядит ровно как раньше:
|
||||
// пустая рамка с заголовком это тоже перемена, и она никому не нужна.
|
||||
const pusto = computed(() => !props.blok || props.blok.nomera.length === 0);
|
||||
|
||||
const nomera = computed<ApiObzvonNomer[]>(() => props.blok?.nomera ?? []);
|
||||
|
||||
// Подпись под номером: телефон и кампания. Два номера или две кампании обязаны
|
||||
// быть РАЗЛИЧИМЫ — иначе человек решит, что видит одну работу вместо двух.
|
||||
function podpisNomera(n: ApiObzvonNomer): string {
|
||||
const kampaniya = n.campaign_id != null ? `список №${n.campaign_id}` : 'без списка';
|
||||
return `${n.phone} · ${kampaniya}`;
|
||||
}
|
||||
|
||||
function nedozvonySlovami(n: number): string {
|
||||
return `Не дозвонились: ${n} раз`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="!pusto" class="section pa-5" data-testid="deal-obzvon">
|
||||
<h3 class="section-title text-subtitle-2 mb-3">Обзвон</h3>
|
||||
|
||||
<!-- Одно верхнее значение — исход последнего СОСТОЯВШЕГОСЯ разговора.
|
||||
Недозвоны его не меняют (Т84): это решено на backend'е одним
|
||||
правилом, здесь только показ. -->
|
||||
<div v-if="blok?.itog" class="obzvon-itog" data-testid="obzvon-itog">
|
||||
<span class="obzvon-itog__label">{{ obzvonIskhodLabel(blok.itog.outcome) }}</span>
|
||||
<span v-if="blok.itog.callback_at" class="obzvon-itog__callback">
|
||||
{{ obzvonVremya(blok.itog.callback_at) }}
|
||||
</span>
|
||||
<span class="obzvon-itog__when text-caption text-medium-emphasis">
|
||||
{{ obzvonVremya(blok.itog.outcome_at) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="text-body-2 text-medium-emphasis mb-3" data-testid="obzvon-itog-pusto">
|
||||
Разговора ещё не было
|
||||
</div>
|
||||
|
||||
<div v-for="nomer in nomera" :key="nomer.itog_id ?? nomer.phone" class="obzvon-nomer" data-testid="obzvon-nomer">
|
||||
<div class="obzvon-nomer__head text-caption text-medium-emphasis num">{{ podpisNomera(nomer) }}</div>
|
||||
|
||||
<ul class="obzvon-lenta">
|
||||
<li
|
||||
v-for="razgovor in nomer.razgovory"
|
||||
:key="razgovor.id"
|
||||
class="obzvon-lenta__item"
|
||||
data-testid="obzvon-razgovor"
|
||||
>
|
||||
<span class="obzvon-lenta__time num">{{ obzvonVremya(razgovor.started_at) }}</span>
|
||||
<span class="obzvon-lenta__dur num">{{ dlitelnostSlovami(razgovor.dlitelnost_sec) }}</span>
|
||||
<span v-if="razgovor.perezvonil_sam" class="obzvon-lenta__vhod">перезвонил сам</span>
|
||||
<span class="obzvon-lenta__zapis text-caption text-medium-emphasis">
|
||||
{{ zapisSlovami(razgovor.zapis) }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- 🔴 Недозвоны СВЁРНУТЫ в одну строку. Двенадцать попыток — это
|
||||
«не дозвонились двенадцать раз», а не двенадцать строк ленты. -->
|
||||
<div
|
||||
v-if="nomer.nedozvonov > 0"
|
||||
class="obzvon-nedozvony text-body-2 text-medium-emphasis"
|
||||
data-testid="obzvon-nedozvony"
|
||||
>
|
||||
{{ nedozvonySlovami(nomer.nedozvonov) }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="nomer.razgovory.length === 0 && nomer.nedozvonov === 0"
|
||||
class="text-body-2 text-medium-emphasis"
|
||||
>
|
||||
Попыток ещё не было
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="blok && blok.kampaniy_skryto > 0"
|
||||
class="obzvon-skryto text-caption text-medium-emphasis"
|
||||
data-testid="obzvon-skryto"
|
||||
>
|
||||
Прошлых списков скрыто: {{ blok.kampaniy_skryto }}
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.section-title {
|
||||
font-weight: 600;
|
||||
color: #081319;
|
||||
}
|
||||
.num {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-feature-settings: 'tnum';
|
||||
}
|
||||
.obzvon-itog {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.obzvon-itog__label {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: #0f6e56;
|
||||
}
|
||||
.obzvon-itog__callback {
|
||||
font-size: 13px;
|
||||
color: #081319;
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
}
|
||||
.obzvon-nomer {
|
||||
border-top: 1px solid #e8e3d6;
|
||||
padding-top: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.obzvon-nomer:first-of-type {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
.obzvon-nomer__head {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.obzvon-lenta {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.obzvon-lenta__item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #081319;
|
||||
}
|
||||
.obzvon-lenta__dur {
|
||||
font-weight: 600;
|
||||
}
|
||||
.obzvon-lenta__vhod {
|
||||
font-size: 11px;
|
||||
padding: 0 8px;
|
||||
border-radius: 10px;
|
||||
background: #eef2f0;
|
||||
color: #33473f;
|
||||
}
|
||||
.obzvon-nedozvony {
|
||||
margin-top: 2px;
|
||||
}
|
||||
.obzvon-skryto {
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -27,6 +27,32 @@ const emit = defineEmits<{
|
||||
|
||||
const SIGNAL_LABELS: Record<string, string> = { call: 'Звонки', site: 'Сайт', sms: 'СМС' };
|
||||
|
||||
/**
|
||||
* Пометки касаний (З-2.4 Т67) — что МЫ уже делали с этим человеком.
|
||||
*
|
||||
* 🔴 Слова подобраны НАРОЧНО другие. Строкой выше «Звонки» и «СМС» означают,
|
||||
* ОТКУДА пришёл лид. Назови пометку теми же словами — и человек прочтёт её
|
||||
* задом наперёд: решит, что лид пришёл со звонка, хотя это мы ему звонили.
|
||||
*
|
||||
* 🔴 Модулей два, а не четыре: у телеграма и рекламы связи со сделкой в базе
|
||||
* не существует вовсе (замерено 06.08.2026). Появится — здесь одно слово.
|
||||
*/
|
||||
const TOUCH_LABELS: Record<string, string> = { obzvon: 'Обзвон', sms: 'Рассылка' };
|
||||
|
||||
// Порядок пометок постоянный: одинаковые сделки не должны выглядеть по-разному
|
||||
// от того, в каком порядке backend перечислил модули.
|
||||
const TOUCH_ORDER = ['obzvon', 'sms'];
|
||||
|
||||
function touchChips(deal: MockDeal): { key: string; label: string; count: number }[] {
|
||||
const t = deal.touches;
|
||||
if (!t) return [];
|
||||
return TOUCH_ORDER.filter((k) => (t[k] ?? 0) > 0).map((k) => ({
|
||||
key: k,
|
||||
label: TOUCH_LABELS[k] ?? k,
|
||||
count: t[k] as number,
|
||||
}));
|
||||
}
|
||||
|
||||
function signalLabel(t: MockDeal['signalType']): string {
|
||||
return t ? (SIGNAL_LABELS[t] ?? '') : '';
|
||||
}
|
||||
@@ -94,6 +120,7 @@ function toggleSelect(id: number): void {
|
||||
{ title: 'Источник', key: 'source', sortable: false },
|
||||
{ title: 'Город', key: 'city', sortable: false },
|
||||
{ title: 'Статус', key: 'statusSlug', sortable: false },
|
||||
{ title: 'Касания', key: 'touches', sortable: false },
|
||||
{ title: 'Комментарий', key: 'comment', sortable: false },
|
||||
{ title: 'Поставлен', key: 'receivedAt', align: 'end', sortable: true },
|
||||
]"
|
||||
@@ -151,6 +178,15 @@ function toggleSelect(id: number): void {
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #[`item.touches`]="{ item }: { item: MockDeal }">
|
||||
<div v-if="touchChips(item).length" class="cell-touches" data-testid="deal-touches">
|
||||
<span v-for="chip in touchChips(item)" :key="chip.key" class="touch-chip">
|
||||
{{ chip.label }}<span class="touch-count num">{{ chip.count }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<span v-else class="text-medium-emphasis">—</span>
|
||||
</template>
|
||||
|
||||
<template #[`item.comment`]="{ item }: { item: MockDeal }">
|
||||
<span
|
||||
class="cell-comment ym-hide-content"
|
||||
@@ -247,6 +283,11 @@ function toggleSelect(id: number): void {
|
||||
<span class="deal-card__signal">{{ sourceSignalLabel(deal) }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="touchChips(deal).length" class="cell-touches deal-card__touches" data-testid="deal-touches">
|
||||
<span v-for="chip in touchChips(deal)" :key="chip.key" class="touch-chip">
|
||||
{{ chip.label }}<span class="touch-count num">{{ chip.count }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="deal.comment" class="deal-card__comment ym-hide-content">{{ deal.comment }}</div>
|
||||
<div class="deal-card__foot num ld-mono-s">{{ formatDateTime(deal.receivedAt) }}</div>
|
||||
</article>
|
||||
@@ -290,6 +331,32 @@ function toggleSelect(id: number): void {
|
||||
.cell-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
/* Пометки касаний — что мы делали сами. Тон намеренно спокойнее «Источника»:
|
||||
это справка, а не признак лида. */
|
||||
.cell-touches {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.touch-chip {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
padding: 0 8px;
|
||||
border-radius: 10px;
|
||||
background: #eef2f0;
|
||||
color: #33473f;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.touch-count {
|
||||
font-weight: 600;
|
||||
color: #0f6e56;
|
||||
}
|
||||
.deal-card__touches {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.cell-comment {
|
||||
display: inline-block;
|
||||
max-width: 240px;
|
||||
|
||||
@@ -104,5 +104,8 @@ export function mapApiDeal(api: ApiDeal, now: Date = new Date()): MockDeal {
|
||||
sourceProjectId: api.source_project_id ?? null,
|
||||
sourceIsActive: api.source_is_active ?? null,
|
||||
sourceExtraCount: api.source_extra_count ?? 0,
|
||||
// Пометки касаний (З-2.4 Т67). Backend отдаёт пустой объект, когда не
|
||||
// трогали ни разу; отсутствие поля у старого ответа — тоже пусто.
|
||||
touches: api.touches ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,6 +37,16 @@ export interface MockDeal {
|
||||
sourceProjectId?: number | null;
|
||||
sourceIsActive?: boolean | null;
|
||||
sourceExtraCount?: number;
|
||||
/**
|
||||
* Пометки касаний (З-2.4 Т67): модуль → сколько раз МЫ трогали человека,
|
||||
* по всей истории, а не последнее действие.
|
||||
*
|
||||
* 🔴 Это НЕ `signalType`. Тот отвечает на вопрос «откуда пришёл лид» и
|
||||
* рисуется словами «Звонки»/«Сайт»/«СМС» в колонке «Источник». Пометка
|
||||
* отвечает на обратный вопрос — «что мы ему сделали» — и называется
|
||||
* другими словами нарочно: одинаковые слова человек прочтёт задом наперёд.
|
||||
*/
|
||||
touches?: Record<string, number> | null;
|
||||
}
|
||||
|
||||
export const MOCK_DEALS: MockDeal[] = [
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Восемь исходов обзвона по-человечески (З-2.4, решение владельца Р112).
|
||||
*
|
||||
* 🔴 ОДНО место на весь интерфейс. Слаги приходят с backend'а как есть
|
||||
* (`obzvon_number_results.outcome`), и восемь слов должны читаться одинаково и
|
||||
* в верхней строке карточки, и в ленте разговоров, и где угодно дальше.
|
||||
*
|
||||
* 🔴 Это НЕ статусы сделки. У сделки своя воронка из 14 значений и свои цвета
|
||||
* (`composables/leadStatuses.ts`). Смешать их — сломать воронку клиента.
|
||||
*/
|
||||
export const OBZVON_ISKHODY: Record<string, string> = {
|
||||
transferred: 'Переведено',
|
||||
transfer_failed: 'Переведено неуспешно',
|
||||
callback: 'Перезвонить',
|
||||
email_sent: 'Письмо клиенту отправлено',
|
||||
not_target: 'Не целевой',
|
||||
refused: 'Отказ',
|
||||
no_answer: 'Не дозвонились',
|
||||
aggression: 'Агрессия',
|
||||
};
|
||||
|
||||
/** Слово по слагу. Неизвестный слаг показываем как есть — врать нельзя. */
|
||||
export function obzvonIskhodLabel(slug: string | null | undefined): string {
|
||||
if (!slug) return '—';
|
||||
return OBZVON_ISKHODY[slug] ?? slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Длительность разговора словами. Секунды у коротких — потому что «хоть
|
||||
* секунда разговора» это и есть предмет спора, и прятать её за «меньше минуты»
|
||||
* значит терять то самое, на что смотрит человек.
|
||||
*/
|
||||
export function dlitelnostSlovami(sec: number): string {
|
||||
if (sec < 60) return `${sec} сек`;
|
||||
const min = Math.floor(sec / 60);
|
||||
const ost = sec % 60;
|
||||
return ost === 0 ? `${min} мин` : `${min} мин ${ost} сек`;
|
||||
}
|
||||
|
||||
/** Дата и время по Москве — как в остальном реестре сделок. */
|
||||
export function obzvonVremya(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'Europe/Moscow',
|
||||
}).format(new Date(iso));
|
||||
}
|
||||
|
||||
/** Что сказать человеку про запись разговора: три разных ответа, не два (Т73). */
|
||||
export function zapisSlovami(zapis: 'est' | 'styorta' | 'ne_bylo'): string {
|
||||
if (zapis === 'est') return 'Запись есть';
|
||||
if (zapis === 'styorta') return 'Запись удалена по сроку хранения';
|
||||
return 'Записи нет';
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* З-2.4 — блок «Обзвон» в карточке сделки (GET /api/deals/{id}).
|
||||
*
|
||||
* 🔴 ОТ КАКОГО ВРЕДА. Менеджер открывает карточку и не знает, звонили человеку
|
||||
* или нет. Он набирает того, кому робот звонил час назад и кому уже пообещали
|
||||
* перезвонить завтра, — человек слышит от одной компании два несогласованных
|
||||
* звонка. Дальше по списку то, что менеджер увидит, если сделать неаккуратно:
|
||||
* 1. в ленте девять строк вместо двух — семь недозвонов выданы за разговоры,
|
||||
* и лента бесполезна;
|
||||
* 2. два разных номера слиты в одну ленту молча — менеджер думает, что
|
||||
* говорили с одним человеком, а говорили с двумя;
|
||||
* 3. статус обзвона поехал в воронку сделки — воронка клиента сломана;
|
||||
* 4. сделка без обзвона обросла пустым блоком у тех, кто обзвоном не
|
||||
* пользуется вовсе;
|
||||
* 5. запись, стёртая по сроку, неотличима от «записи не было» — портал врёт
|
||||
* человеку про то, чего у нас никогда не было.
|
||||
*
|
||||
* 🔴 Данные заведены РУКАМИ: робот ещё не звонит, живой работы модуля нет —
|
||||
* это названо вслух, а не выдано за приёмку на боевых данных.
|
||||
*/
|
||||
|
||||
use App\Models\Deal;
|
||||
use App\Models\ObzvonCall;
|
||||
use App\Models\ObzvonNumberResult;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Obzvon\ItogPoNomeru;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->user = User::factory()->for($this->tenant)->create();
|
||||
$this->actingAs($this->user);
|
||||
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$this->project = Project::factory()->for($this->tenant)->create();
|
||||
$this->deal = Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
|
||||
});
|
||||
|
||||
/** @param array<string, mixed> $svoyo */
|
||||
function z24Zvonok(Deal $deal, ObzvonNumberResult $itog, array $svoyo = []): ObzvonCall
|
||||
{
|
||||
return ObzvonCall::create(array_merge([
|
||||
'tenant_id' => $deal->tenant_id,
|
||||
'number_result_id' => $itog->id,
|
||||
'campaign_id' => $itog->campaign_id,
|
||||
'deal_id' => $deal->id,
|
||||
'deal_received_at' => $deal->received_at,
|
||||
'phone' => $itog->phone,
|
||||
'started_at' => Carbon::parse('2026-08-06 10:00:00'),
|
||||
'answered' => false,
|
||||
'leg1_duration_sec' => 0,
|
||||
'attempt_outcome' => ObzvonCall::ATTEMPT_NO_ANSWER,
|
||||
], $svoyo));
|
||||
}
|
||||
|
||||
function z24ItogSdelki(Deal $deal, int $kampaniya = 1, string $telefon = '79001112233'): ObzvonNumberResult
|
||||
{
|
||||
return ObzvonNumberResult::create([
|
||||
'tenant_id' => $deal->tenant_id,
|
||||
'campaign_id' => $kampaniya,
|
||||
'phone' => $telefon,
|
||||
'deal_id' => $deal->id,
|
||||
'deal_received_at' => $deal->received_at,
|
||||
]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('сделка без обзвона выглядит как раньше — пустой блок, ничего не сломалось', function () {
|
||||
$r = $this->getJson('/api/deals/'.$this->deal->id);
|
||||
|
||||
$r->assertStatus(200);
|
||||
expect($r->json('obzvon.itog'))->toBeNull()
|
||||
->and($r->json('obzvon.nomera'))->toBe([])
|
||||
// Прежний состав карточки не тронут.
|
||||
->and($r->json('deal.status'))->toBe('new')
|
||||
->and($r->json('events'))->toBeArray();
|
||||
});
|
||||
|
||||
test('два разговора и семь недозвонов — в ленте ДВЕ строки и счётчик 7', function () {
|
||||
$itog = z24ItogSdelki($this->deal);
|
||||
|
||||
for ($i = 0; $i < 7; $i++) {
|
||||
z24Zvonok($this->deal, $itog, [
|
||||
'attempt_no' => $i + 1,
|
||||
'started_at' => Carbon::parse('2026-08-06 09:00:00')->addMinutes($i),
|
||||
]);
|
||||
}
|
||||
z24Zvonok($this->deal, $itog, [
|
||||
'attempt_no' => 8,
|
||||
'started_at' => Carbon::parse('2026-08-06 12:00:00'),
|
||||
'answered' => true,
|
||||
'leg1_duration_sec' => 45,
|
||||
'attempt_outcome' => ObzvonCall::ATTEMPT_ANSWERED,
|
||||
]);
|
||||
z24Zvonok($this->deal, $itog, [
|
||||
'attempt_no' => 9,
|
||||
'started_at' => Carbon::parse('2026-08-06 13:00:00'),
|
||||
'answered' => true,
|
||||
'leg1_duration_sec' => 120,
|
||||
'attempt_outcome' => ObzvonCall::ATTEMPT_ANSWERED,
|
||||
]);
|
||||
|
||||
$r = $this->getJson('/api/deals/'.$this->deal->id);
|
||||
|
||||
expect($r->json('obzvon.nomera'))->toHaveCount(1)
|
||||
->and($r->json('obzvon.nomera.0.razgovory'))->toHaveCount(2)
|
||||
->and($r->json('obzvon.nomera.0.nedozvonov'))->toBe(7);
|
||||
});
|
||||
|
||||
test('разговор нулевой длины в ленту не попадает, секундный — попадает', function () {
|
||||
$itog = z24ItogSdelki($this->deal);
|
||||
|
||||
z24Zvonok($this->deal, $itog, ['answered' => true, 'leg1_duration_sec' => 0, 'attempt_outcome' => ObzvonCall::ATTEMPT_ANSWERED]);
|
||||
z24Zvonok($this->deal, $itog, [
|
||||
'attempt_no' => 2,
|
||||
'started_at' => Carbon::parse('2026-08-06 11:00:00'),
|
||||
'answered' => true,
|
||||
'leg1_duration_sec' => 1,
|
||||
'attempt_outcome' => ObzvonCall::ATTEMPT_ANSWERED,
|
||||
]);
|
||||
|
||||
$r = $this->getJson('/api/deals/'.$this->deal->id);
|
||||
|
||||
expect($r->json('obzvon.nomera.0.razgovory'))->toHaveCount(1)
|
||||
->and($r->json('obzvon.nomera.0.razgovory.0.dlitelnost_sec'))->toBe(1)
|
||||
->and($r->json('obzvon.nomera.0.nedozvonov'))->toBe(1);
|
||||
});
|
||||
|
||||
test('два разных номера на одной сделке НЕ слиты в одну ленту', function () {
|
||||
$pervyy = z24ItogSdelki($this->deal, kampaniya: 1, telefon: '79001110001');
|
||||
$vtoroy = z24ItogSdelki($this->deal, kampaniya: 1, telefon: '79001110002');
|
||||
|
||||
z24Zvonok($this->deal, $pervyy, ['answered' => true, 'leg1_duration_sec' => 30, 'attempt_outcome' => ObzvonCall::ATTEMPT_ANSWERED]);
|
||||
z24Zvonok($this->deal, $vtoroy, [
|
||||
'started_at' => Carbon::parse('2026-08-06 11:00:00'),
|
||||
'answered' => true,
|
||||
'leg1_duration_sec' => 40,
|
||||
'attempt_outcome' => ObzvonCall::ATTEMPT_ANSWERED,
|
||||
]);
|
||||
|
||||
$r = $this->getJson('/api/deals/'.$this->deal->id);
|
||||
|
||||
$nomera = $r->json('obzvon.nomera');
|
||||
expect($nomera)->toHaveCount(2)
|
||||
->and(collect($nomera)->pluck('phone')->sort()->values()->all())->toBe(['79001110001', '79001110002'])
|
||||
->and(collect($nomera)->pluck('razgovory')->map(fn ($r) => count($r))->all())->toBe([1, 1]);
|
||||
});
|
||||
|
||||
test('тот же номер новым списком — прошлый итог в карточке ВИДЕН отдельной записью', function () {
|
||||
$staryy = z24ItogSdelki($this->deal, kampaniya: 1, telefon: '79001112233');
|
||||
$novyy = z24ItogSdelki($this->deal, kampaniya: 2, telefon: '79001112233');
|
||||
|
||||
$staryy->update(['outcome' => ObzvonNumberResult::OUTCOME_REFUSED, 'outcome_at' => Carbon::parse('2026-07-01 10:00:00')]);
|
||||
$novyy->update(['outcome' => ObzvonNumberResult::OUTCOME_CALLBACK, 'outcome_at' => Carbon::parse('2026-08-06 10:00:00')]);
|
||||
|
||||
$r = $this->getJson('/api/deals/'.$this->deal->id);
|
||||
|
||||
$nomera = $r->json('obzvon.nomera');
|
||||
expect($nomera)->toHaveCount(2)
|
||||
->and(collect($nomera)->pluck('campaign_id')->sort()->values()->all())->toBe([1, 2])
|
||||
// Верх берёт свежий, прошлое остаётся видно.
|
||||
->and($r->json('obzvon.itog.outcome'))->toBe(ObzvonNumberResult::OUTCOME_CALLBACK);
|
||||
});
|
||||
|
||||
test('недозвон, пришедший позже разговора, верхнюю строку карточки не меняет', function () {
|
||||
$razgovorom = z24ItogSdelki($this->deal, kampaniya: 1, telefon: '79001110001');
|
||||
$nedozvonom = z24ItogSdelki($this->deal, kampaniya: 1, telefon: '79001110002');
|
||||
|
||||
$razgovorom->update(['outcome' => ObzvonNumberResult::OUTCOME_TRANSFERRED, 'outcome_at' => Carbon::parse('2026-08-06 10:00:00')]);
|
||||
// 🔴 Недозвон по ДРУГОМУ номеру и со СВЕЖИМ временем: выбор верхней строки
|
||||
// обязан идти по смыслу, а не по «чьё время больше».
|
||||
$nedozvonom->update(['outcome' => ObzvonNumberResult::OUTCOME_NO_ANSWER, 'outcome_at' => Carbon::parse('2026-08-06 23:00:00')]);
|
||||
|
||||
$r = $this->getJson('/api/deals/'.$this->deal->id);
|
||||
|
||||
expect($r->json('obzvon.itog.outcome'))->toBe(ObzvonNumberResult::OUTCOME_TRANSFERRED)
|
||||
->and($r->json('obzvon.itog.phone'))->toBe('79001110001');
|
||||
});
|
||||
|
||||
test('запись стёртая по сроку отличима от «записи не было»', function () {
|
||||
$itog = z24ItogSdelki($this->deal);
|
||||
|
||||
z24Zvonok($this->deal, $itog, [
|
||||
'answered' => true, 'leg1_duration_sec' => 30, 'attempt_outcome' => ObzvonCall::ATTEMPT_ANSWERED,
|
||||
'recording_path' => 'obzvon/1.mp3', 'recording_deleted_at' => Carbon::parse('2026-08-05 00:00:00'),
|
||||
]);
|
||||
z24Zvonok($this->deal, $itog, [
|
||||
'attempt_no' => 2, 'started_at' => Carbon::parse('2026-08-06 11:00:00'),
|
||||
'answered' => true, 'leg1_duration_sec' => 30, 'attempt_outcome' => ObzvonCall::ATTEMPT_ANSWERED,
|
||||
]);
|
||||
z24Zvonok($this->deal, $itog, [
|
||||
'attempt_no' => 3, 'started_at' => Carbon::parse('2026-08-06 12:00:00'),
|
||||
'answered' => true, 'leg1_duration_sec' => 30, 'attempt_outcome' => ObzvonCall::ATTEMPT_ANSWERED,
|
||||
'recording_path' => 'obzvon/3.mp3',
|
||||
]);
|
||||
|
||||
$r = $this->getJson('/api/deals/'.$this->deal->id);
|
||||
|
||||
expect(collect($r->json('obzvon.nomera.0.razgovory'))->pluck('zapis')->all())
|
||||
->toBe(['styorta', 'ne_bylo', 'est']);
|
||||
});
|
||||
|
||||
test('входящий звонок — человек перезвонил сам — тоже строка ленты', function () {
|
||||
$itog = z24ItogSdelki($this->deal);
|
||||
|
||||
z24Zvonok($this->deal, $itog, [
|
||||
'direction' => ObzvonCall::DIRECTION_INBOUND,
|
||||
'answered' => true, 'leg1_duration_sec' => 20, 'attempt_outcome' => ObzvonCall::ATTEMPT_ANSWERED,
|
||||
]);
|
||||
|
||||
$r = $this->getJson('/api/deals/'.$this->deal->id);
|
||||
|
||||
expect($r->json('obzvon.nomera.0.razgovory'))->toHaveCount(1)
|
||||
->and($r->json('obzvon.nomera.0.razgovory.0.perezvonil_sam'))->toBeTrue();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Вырезание второго рода — случаи, найденные помощником сверх задания
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('номер в списке есть, а звонков ещё не было — карточка говорит об этом словами', function () {
|
||||
// Допущение защиты: «нет звонков — значит и блока нет». Случай мимо него:
|
||||
// список заведён, номер в работе, но робот до него ещё не дошёл. Промолчать
|
||||
// здесь значит соврать «обзвона не было», хотя он назначен.
|
||||
z24ItogSdelki($this->deal);
|
||||
|
||||
$r = $this->getJson('/api/deals/'.$this->deal->id);
|
||||
|
||||
expect($r->json('obzvon.nomera'))->toHaveCount(1)
|
||||
->and($r->json('obzvon.nomera.0.razgovory'))->toBe([])
|
||||
->and($r->json('obzvon.nomera.0.nedozvonov'))->toBe(0)
|
||||
->and($r->json('obzvon.itog'))->toBeNull();
|
||||
});
|
||||
|
||||
test('звонки ЧУЖОГО клиента в карточку не протекают', function () {
|
||||
// Допущение защиты: «сводка читает по deal_id». Случай мимо него: номер
|
||||
// сделки у другого арендатора совпал. Показать чужой разговор — утечка
|
||||
// персональных данных, а не косметика.
|
||||
$chuzhoy = Tenant::factory()->create();
|
||||
DB::statement('SET app.current_tenant_id = '.$chuzhoy->id);
|
||||
ObzvonCall::create([
|
||||
'tenant_id' => $chuzhoy->id,
|
||||
'deal_id' => $this->deal->id,
|
||||
'deal_received_at' => $this->deal->received_at,
|
||||
'phone' => '79009998877',
|
||||
'started_at' => Carbon::parse('2026-08-06 10:00:00'),
|
||||
'answered' => true,
|
||||
'leg1_duration_sec' => 99,
|
||||
'attempt_outcome' => ObzvonCall::ATTEMPT_ANSWERED,
|
||||
]);
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
|
||||
$r = $this->getJson('/api/deals/'.$this->deal->id);
|
||||
|
||||
expect($r->json('obzvon.nomera'))->toBe([])
|
||||
->and($r->json('obzvon.itog'))->toBeNull();
|
||||
});
|
||||
|
||||
test('звонок без итога по номеру не подмешивается к чужой ленте', function () {
|
||||
// Допущение защиты: «у каждого звонка есть итог». Случай мимо него: звонок
|
||||
// приехал раньше, чем завели итог (`number_result_id` пуст). Подмешать его
|
||||
// к соседнему номеру значит соврать, с кем говорили.
|
||||
$itog = z24ItogSdelki($this->deal, kampaniya: 1, telefon: '79001110001');
|
||||
z24Zvonok($this->deal, $itog, ['answered' => true, 'leg1_duration_sec' => 30, 'attempt_outcome' => ObzvonCall::ATTEMPT_ANSWERED]);
|
||||
|
||||
ObzvonCall::create([
|
||||
'tenant_id' => $this->deal->tenant_id,
|
||||
'number_result_id' => null,
|
||||
'deal_id' => $this->deal->id,
|
||||
'deal_received_at' => $this->deal->received_at,
|
||||
'phone' => '79001110002',
|
||||
'started_at' => Carbon::parse('2026-08-06 11:00:00'),
|
||||
'answered' => true,
|
||||
'leg1_duration_sec' => 20,
|
||||
'attempt_outcome' => ObzvonCall::ATTEMPT_ANSWERED,
|
||||
]);
|
||||
|
||||
$r = $this->getJson('/api/deals/'.$this->deal->id);
|
||||
|
||||
$nomera = collect($r->json('obzvon.nomera'));
|
||||
expect($nomera)->toHaveCount(2)
|
||||
->and($nomera->firstWhere('phone', '79001110001')['razgovory'])->toHaveCount(1)
|
||||
->and($nomera->firstWhere('phone', '79001110002')['razgovory'])->toHaveCount(1)
|
||||
// У безитогового номера верхней строки нет — выдумывать её нельзя.
|
||||
->and($nomera->firstWhere('phone', '79001110002')['outcome'])->toBeNull();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 🔴 Проверка 1 задания: воронка сделки не должна шелохнуться
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('исход обзвона меняется — статус сделки в БАЗЕ не трогается', function () {
|
||||
$itog = z24ItogSdelki($this->deal);
|
||||
$dver = new ItogPoNomeru;
|
||||
|
||||
$do = DB::table('deals')->where('id', $this->deal->id)->value('status');
|
||||
|
||||
foreach (ObzvonNumberResult::OUTCOMES as $nomer => $outcome) {
|
||||
$dver->zapisat($itog, $outcome, Carbon::parse('2026-08-06 10:00:00')->addMinutes($nomer));
|
||||
$this->getJson('/api/deals/'.$this->deal->id)->assertStatus(200);
|
||||
}
|
||||
|
||||
$posle = DB::table('deals')->where('id', $this->deal->id)->value('status');
|
||||
|
||||
expect($posle)->toBe($do)->toBe('new');
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* З-2.4 Т67 — пометки касаний в строке списка сделок (GET /api/deals).
|
||||
*
|
||||
* 🔴 ОТ КАКОГО ВРЕДА:
|
||||
* 1. менеджер не видит в списке, что человека уже трогали, и звонит поверх
|
||||
* данного обещания — ровно беда задачи;
|
||||
* 2. видно только ПОСЛЕДНЕЕ касание: человеку слали СМС и звонили, а в
|
||||
* строке одно из двух — и второе теряется;
|
||||
* 3. 🔴 таблица стала медленнее: пометки собираются запросом НА КАЖДУЮ
|
||||
* СТРОКУ. На этой грабле проект стоял дважды за неделю, и мерить её надо
|
||||
* числом запросов на списке из пятидесяти сделок, а не секундомером и не
|
||||
* на одной сделке: одна сделка даёт один запрос и при годном устройстве,
|
||||
* и при негодном;
|
||||
* 4. пометка названа словом, которое на этом экране уже занято: «Звонки» и
|
||||
* «СМС» в колонке «Источник» означают, ОТКУДА пришёл лид, — человек
|
||||
* прочтёт пометку задом наперёд.
|
||||
*/
|
||||
|
||||
use App\Models\ClientSmsMessage;
|
||||
use App\Models\Deal;
|
||||
use App\Models\ObzvonCall;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Obzvon\SvodkaObzvonaVSdelke;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->user = User::factory()->for($this->tenant)->create();
|
||||
$this->actingAs($this->user);
|
||||
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$this->project = Project::factory()->for($this->tenant)->create();
|
||||
});
|
||||
|
||||
/** Сделка со звонком и СМС; номер телефона свой у каждой — иначе замок СМС. */
|
||||
function z24SdelkaSKasaniyami(int $tenantId, int $projectId, int $nomer): Deal
|
||||
{
|
||||
$deal = Deal::factory()->create([
|
||||
'tenant_id' => $tenantId,
|
||||
'project_id' => $projectId,
|
||||
'phone' => '7900'.str_pad((string) $nomer, 7, '0', STR_PAD_LEFT),
|
||||
]);
|
||||
|
||||
ObzvonCall::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'deal_id' => $deal->id,
|
||||
'deal_received_at' => $deal->received_at,
|
||||
'phone' => $deal->phone,
|
||||
'started_at' => Carbon::parse('2026-08-06 10:00:00'),
|
||||
'answered' => true,
|
||||
'leg1_duration_sec' => 30,
|
||||
'attempt_outcome' => ObzvonCall::ATTEMPT_ANSWERED,
|
||||
]);
|
||||
|
||||
ClientSmsMessage::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'campaign_id' => $nomer,
|
||||
'deal_id' => $deal->id,
|
||||
'phone' => $deal->phone,
|
||||
'status' => ClientSmsMessage::STATUS_SENT,
|
||||
]);
|
||||
|
||||
return $deal;
|
||||
}
|
||||
|
||||
/** Сколько запросов ушло в базу за один вызов. */
|
||||
function z24SchetZaprosov(callable $chto): int
|
||||
{
|
||||
$schyot = 0;
|
||||
DB::listen(function () use (&$schyot) {
|
||||
$schyot++;
|
||||
});
|
||||
|
||||
$chto();
|
||||
|
||||
return $schyot;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('пометки касаний показывают ВСЮ историю, а не последнее действие', function () {
|
||||
$deal = z24SdelkaSKasaniyami($this->tenant->id, $this->project->id, 1);
|
||||
|
||||
// Второй звонок и вторая СМС — история, а не «последнее».
|
||||
ObzvonCall::create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'deal_id' => $deal->id,
|
||||
'deal_received_at' => $deal->received_at,
|
||||
'phone' => $deal->phone,
|
||||
'started_at' => Carbon::parse('2026-08-06 12:00:00'),
|
||||
'answered' => false,
|
||||
'leg1_duration_sec' => 0,
|
||||
'attempt_outcome' => ObzvonCall::ATTEMPT_NO_ANSWER,
|
||||
]);
|
||||
ClientSmsMessage::create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'campaign_id' => 777,
|
||||
'deal_id' => $deal->id,
|
||||
'phone' => $deal->phone,
|
||||
'status' => ClientSmsMessage::STATUS_SENT,
|
||||
]);
|
||||
|
||||
$r = $this->getJson('/api/deals?limit=50');
|
||||
|
||||
$stroka = collect($r->json('deals'))->firstWhere('id', $deal->id);
|
||||
expect($stroka['touches'])->toBe(['obzvon' => 2, 'sms' => 2]);
|
||||
});
|
||||
|
||||
test('сделка без касаний — пометок нет вовсе', function () {
|
||||
$deal = Deal::factory()->create(['tenant_id' => $this->tenant->id, 'project_id' => $this->project->id]);
|
||||
|
||||
$r = $this->getJson('/api/deals?limit=50');
|
||||
|
||||
$stroka = collect($r->json('deals'))->firstWhere('id', $deal->id);
|
||||
expect($stroka['touches'])->toBe([]);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 🔴 Проверка 6 задания: число запросов не растёт вместе со списком
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('пятьдесят сделок с касаниями стоят столько же запросов, сколько пять', function () {
|
||||
for ($i = 1; $i <= 5; $i++) {
|
||||
z24SdelkaSKasaniyami($this->tenant->id, $this->project->id, $i);
|
||||
}
|
||||
$naPyati = z24SchetZaprosov(fn () => $this->getJson('/api/deals?limit=5')->assertStatus(200));
|
||||
|
||||
for ($i = 6; $i <= 50; $i++) {
|
||||
z24SdelkaSKasaniyami($this->tenant->id, $this->project->id, $i);
|
||||
}
|
||||
$naPyatidesyati = z24SchetZaprosov(fn () => $this->getJson('/api/deals?limit=50')->assertStatus(200));
|
||||
|
||||
// Ровно столько же: пометки собираются одним запросом на модуль на всю
|
||||
// страницу. Вырастет хоть на один — устройство поехало в запрос на строку.
|
||||
expect($naPyatidesyati)->toBe($naPyati);
|
||||
});
|
||||
|
||||
test('сбор пометок стоит ТРИ запроса и на одной сделке, и на пятидесяти', function () {
|
||||
$svodka = new SvodkaObzvonaVSdelke;
|
||||
|
||||
$odna = [z24SdelkaSKasaniyami($this->tenant->id, $this->project->id, 1)->id];
|
||||
$naOdnoy = z24SchetZaprosov(fn () => $svodka->pometkiKasaniy($this->tenant->id, $odna));
|
||||
|
||||
$pyatdesyat = $odna;
|
||||
for ($i = 2; $i <= 50; $i++) {
|
||||
$pyatdesyat[] = z24SdelkaSKasaniyami($this->tenant->id, $this->project->id, $i)->id;
|
||||
}
|
||||
$naPyatidesyati = z24SchetZaprosov(fn () => $svodka->pometkiKasaniy($this->tenant->id, $pyatdesyat));
|
||||
|
||||
// Три: выставление арендатора + по одному запросу на модуль. Число прибито
|
||||
// нарочно — третий модуль обязан быть ЗАМЕЧЕН этим сторожем, а не проехать
|
||||
// молча, и запрос на строку обязан его сорвать.
|
||||
expect($naOdnoy)->toBe(3)
|
||||
->and($naPyatidesyati)->toBe(3);
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* З-2.4 — верхняя строка «Обзвон» в карточке сделки: чем недозвон отличается
|
||||
* от остальных семи исходов.
|
||||
*
|
||||
* 🔴 ОТ КАКОГО ВРЕДА СТЕРЕГУТ ЭТИ ПРОВЕРКИ. Не от «класса нет в папке», а от
|
||||
* того, что менеджер увидит на экране:
|
||||
* 1. робот поговорил с человеком, потом двенадцать раз не дозвонился — и в
|
||||
* карточке вместо «переведено» стоит «не дозвонились». Менеджер решает,
|
||||
* что с человеком не говорили, и звонит сам поверх уже данного обещания;
|
||||
* 2. обратное: не дозвонились НИ РАЗУ, а карточка пустая — менеджер не
|
||||
* понимает, работали по этому номеру вообще или нет;
|
||||
* 3. отчёт робота приехал с опозданием и переписал более свежий исход —
|
||||
* карточка показывает вчерашнее как сегодняшнее;
|
||||
* 4. исход «перезвонить» сменился на «отказ», а обещанное время перезвона
|
||||
* осталось стоять — менеджер видит срок, которого никто не обещал;
|
||||
* 5. раскладка в настройке разъехалась с восемью исходами модели, и
|
||||
* какой-то исход провалился мимо обоих списков МОЛЧА.
|
||||
*
|
||||
* 🔴 Зовущих в боевом коде у ItogPoNomeru сегодня НОЛЬ — робот ещё не звонит,
|
||||
* итог по номеру не пишет никто (замерено 06.08.2026 перечнем по всему коду).
|
||||
* Дверь поставлена ДО первого пишущего, и эти сторожа — её единственные
|
||||
* зовущие. Названо вслух, а не выдано за живую работу.
|
||||
*/
|
||||
|
||||
use App\Models\ObzvonNumberResult;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Obzvon\ItogPoNomeru;
|
||||
use App\Services\Obzvon\RaskladkaIskhodov;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$this->dver = new ItogPoNomeru;
|
||||
});
|
||||
|
||||
/** Заготовка: пустой итог по номеру. Приставка z24 — функции Pest глобальные. */
|
||||
function z24Itog(int $tenantId, int $kampaniya = 1, string $telefon = '79001112233'): ObzvonNumberResult
|
||||
{
|
||||
return ObzvonNumberResult::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'campaign_id' => $kampaniya,
|
||||
'phone' => $telefon,
|
||||
]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 🔴🔴 ГЛАВНАЯ ЛОВУШКА ЗАДАЧИ
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('недозвон ПОСЛЕ удачного разговора и со СВЕЖИМ временем верх не меняет', function () {
|
||||
$itog = z24Itog($this->tenant->id);
|
||||
|
||||
$razgovor = Carbon::parse('2026-08-06 10:00:00');
|
||||
expect($this->dver->zapisat($itog, ObzvonNumberResult::OUTCOME_TRANSFERRED, $razgovor))->toBeTrue();
|
||||
|
||||
// 🔴 Недозвон приезжает ПОЗЖЕ и с более свежей меткой времени. Сторож,
|
||||
// который смотрел бы на порядок вставки или на «чьё время больше», здесь
|
||||
// пропустил бы затирание.
|
||||
$nedozvon = Carbon::parse('2026-08-06 18:30:00');
|
||||
$peremenilos = $this->dver->zapisat($itog, ObzvonNumberResult::OUTCOME_NO_ANSWER, $nedozvon);
|
||||
|
||||
expect($peremenilos)->toBeFalse();
|
||||
|
||||
$vBaze = DB::table('obzvon_number_results')->where('id', $itog->id)->first();
|
||||
expect($vBaze->outcome)->toBe(ObzvonNumberResult::OUTCOME_TRANSFERRED)
|
||||
->and(Carbon::parse($vBaze->outcome_at)->equalTo($razgovor))->toBeTrue()
|
||||
// Объект в памяти тоже обязан остаться прежним: иначе следом
|
||||
// сохранённая «шапка» затрёт то, что дверь только что отбила.
|
||||
->and($itog->outcome)->toBe(ObzvonNumberResult::OUTCOME_TRANSFERRED);
|
||||
});
|
||||
|
||||
test('двенадцать недозвонов подряд дают ОДИН итог «не дозвонились»', function () {
|
||||
$itog = z24Itog($this->tenant->id);
|
||||
|
||||
for ($i = 1; $i <= 12; $i++) {
|
||||
$this->dver->zapisat($itog, ObzvonNumberResult::OUTCOME_NO_ANSWER, Carbon::parse('2026-08-06 09:00:00')->addMinutes($i));
|
||||
$this->dver->otmetitPopytku($itog, Carbon::parse('2026-08-06 09:00:00')->addMinutes($i));
|
||||
}
|
||||
|
||||
expect(DB::table('obzvon_number_results')->where('deal_id', null)->where('tenant_id', $this->tenant->id)->count())->toBe(1);
|
||||
|
||||
$vBaze = DB::table('obzvon_number_results')->where('id', $itog->id)->first();
|
||||
expect($vBaze->outcome)->toBe(ObzvonNumberResult::OUTCOME_NO_ANSWER)
|
||||
->and((int) $vBaze->attempts_count)->toBe(12);
|
||||
});
|
||||
|
||||
test('разговор ложится поверх недозвона — состоявшийся разговор старше по смыслу', function () {
|
||||
$itog = z24Itog($this->tenant->id);
|
||||
|
||||
$this->dver->zapisat($itog, ObzvonNumberResult::OUTCOME_NO_ANSWER, Carbon::parse('2026-08-06 12:00:00'));
|
||||
$peremenilos = $this->dver->zapisat($itog, ObzvonNumberResult::OUTCOME_REFUSED, Carbon::parse('2026-08-06 12:05:00'));
|
||||
|
||||
expect($peremenilos)->toBeTrue()
|
||||
->and(DB::table('obzvon_number_results')->where('id', $itog->id)->first()->outcome)
|
||||
->toBe(ObzvonNumberResult::OUTCOME_REFUSED);
|
||||
});
|
||||
|
||||
test('опоздавший отчёт не переписывает более свежий исход', function () {
|
||||
$itog = z24Itog($this->tenant->id);
|
||||
|
||||
$this->dver->zapisat($itog, ObzvonNumberResult::OUTCOME_TRANSFERRED, Carbon::parse('2026-08-06 15:00:00'));
|
||||
$peremenilos = $this->dver->zapisat($itog, ObzvonNumberResult::OUTCOME_NOT_TARGET, Carbon::parse('2026-08-06 11:00:00'));
|
||||
|
||||
expect($peremenilos)->toBeFalse()
|
||||
->and(DB::table('obzvon_number_results')->where('id', $itog->id)->first()->outcome)
|
||||
->toBe(ObzvonNumberResult::OUTCOME_TRANSFERRED);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Восемь значений, время перезвона, целость раскладки
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('все восемь исходов проставляются и различимы', function () {
|
||||
$vidennye = [];
|
||||
|
||||
foreach (ObzvonNumberResult::OUTCOMES as $nomer => $outcome) {
|
||||
$itog = z24Itog($this->tenant->id, kampaniya: 100 + $nomer, telefon: '7900111'.str_pad((string) $nomer, 4, '0', STR_PAD_LEFT));
|
||||
$this->dver->zapisat($itog, $outcome, Carbon::parse('2026-08-06 10:00:00'));
|
||||
$vidennye[] = DB::table('obzvon_number_results')->where('id', $itog->id)->first()->outcome;
|
||||
}
|
||||
|
||||
expect($vidennye)->toBe(ObzvonNumberResult::OUTCOMES)
|
||||
->and(count(array_unique($vidennye)))->toBe(8);
|
||||
});
|
||||
|
||||
test('время перезвона не переживает смену исхода', function () {
|
||||
$itog = z24Itog($this->tenant->id);
|
||||
|
||||
$this->dver->zapisat(
|
||||
$itog,
|
||||
ObzvonNumberResult::OUTCOME_CALLBACK,
|
||||
Carbon::parse('2026-08-06 10:00:00'),
|
||||
Carbon::parse('2026-08-07 09:00:00'),
|
||||
);
|
||||
expect(DB::table('obzvon_number_results')->where('id', $itog->id)->first()->callback_at)->not->toBeNull();
|
||||
|
||||
$this->dver->zapisat($itog, ObzvonNumberResult::OUTCOME_REFUSED, Carbon::parse('2026-08-06 11:00:00'));
|
||||
|
||||
expect(DB::table('obzvon_number_results')->where('id', $itog->id)->first()->callback_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('неизвестный исход дверь не принимает', function () {
|
||||
$itog = z24Itog($this->tenant->id);
|
||||
|
||||
expect(fn () => $this->dver->zapisat($itog, 'ne_dozvonilis'))
|
||||
->toThrow(InvalidArgumentException::class);
|
||||
});
|
||||
|
||||
test('раскладка в настройке покрывает ровно восемь исходов', function () {
|
||||
expect(RaskladkaIskhodov::nepokrytye())->toBe([]);
|
||||
|
||||
$proNomer = array_filter(ObzvonNumberResult::OUTCOMES, RaskladkaIskhodov::proNomer(...));
|
||||
$proPopytku = array_filter(ObzvonNumberResult::OUTCOMES, RaskladkaIskhodov::proPopytku(...));
|
||||
|
||||
// Ни один исход не может быть обоих родов сразу — иначе правило Т84
|
||||
// отвечало бы по-разному в зависимости от порядка проверки.
|
||||
expect(array_intersect($proNomer, $proPopytku))->toBe([])
|
||||
->and(count($proNomer) + count($proPopytku))->toBe(8);
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* З-2.4 Р112 — блок «Обзвон» в карточке сделки.
|
||||
*
|
||||
* 🔴 ОТ КАКОГО ВРЕДА:
|
||||
* 1. недозвоны показаны строками ленты — девять строк вместо двух, и лента
|
||||
* бесполезна;
|
||||
* 2. верхнее значение показывает недозвон, хотя разговор состоялся, —
|
||||
* менеджер решает, что с человеком не говорили, и звонит поверх обещания;
|
||||
* 3. два номера слиты в одну ленту молча;
|
||||
* 4. у сделки без обзвона появилась пустая рамка с заголовком;
|
||||
* 5. запись, стёртая по сроку, показана как «записи нет» — портал врёт про
|
||||
* то, чего у нас никогда не было.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import DealDetailObzvon from '../../resources/js/components/deals/DealDetailObzvon.vue';
|
||||
import type { ApiObzvonBlok, ApiObzvonNomer, ApiObzvonRazgovor } from '../../resources/js/api/deals';
|
||||
|
||||
const vuetify = createVuetify();
|
||||
|
||||
function razgovor(over: Partial<ApiObzvonRazgovor> = {}): ApiObzvonRazgovor {
|
||||
return {
|
||||
id: 1,
|
||||
started_at: '2026-08-06T09:00:00+00:00',
|
||||
dlitelnost_sec: 45,
|
||||
perezvonil_sam: false,
|
||||
attempt_outcome: 'answered',
|
||||
zapis: 'est',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function nomer(over: Partial<ApiObzvonNomer> = {}): ApiObzvonNomer {
|
||||
return {
|
||||
itog_id: 1,
|
||||
phone: '79001110001',
|
||||
campaign_id: 1,
|
||||
outcome: 'transferred',
|
||||
outcome_at: '2026-08-06T09:01:00+00:00',
|
||||
callback_at: null,
|
||||
popytok_vsego: 9,
|
||||
last_call_at: '2026-08-06T09:01:00+00:00',
|
||||
nedozvonov: 0,
|
||||
razgovory: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function smontirovat(blok: ApiObzvonBlok | null) {
|
||||
return mount(DealDetailObzvon, { props: { blok }, global: { plugins: [vuetify] } });
|
||||
}
|
||||
|
||||
describe('блок «Обзвон» в карточке сделки', () => {
|
||||
it('сделка без обзвона — блока нет вовсе, а не пустая рамка', () => {
|
||||
expect(smontirovat(null).find('[data-testid="deal-obzvon"]').exists()).toBe(false);
|
||||
expect(
|
||||
smontirovat({ itog: null, nomera: [], kampaniy_skryto: 0 }).find('[data-testid="deal-obzvon"]').exists(),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('🔴 два разговора и семь недозвонов — ДВЕ строки ленты и одна со счётчиком 7', () => {
|
||||
const w = smontirovat({
|
||||
itog: { outcome: 'transferred', outcome_at: '2026-08-06T09:01:00+00:00', callback_at: null, phone: '79001110001' },
|
||||
nomera: [
|
||||
nomer({
|
||||
nedozvonov: 7,
|
||||
razgovory: [razgovor({ id: 8 }), razgovor({ id: 9, dlitelnost_sec: 120 })],
|
||||
}),
|
||||
],
|
||||
kampaniy_skryto: 0,
|
||||
});
|
||||
|
||||
expect(w.findAll('[data-testid="obzvon-razgovor"]')).toHaveLength(2);
|
||||
expect(w.find('[data-testid="obzvon-nedozvony"]').text()).toContain('7');
|
||||
});
|
||||
|
||||
it('🔴 верхнее значение — исход разговора, даже если недозвон пришёл позже', () => {
|
||||
// Backend уже решил спор; здесь сторожим, что показ не выберет что-то
|
||||
// другое, например «последний по списку номер».
|
||||
const w = smontirovat({
|
||||
itog: { outcome: 'transferred', outcome_at: '2026-08-06T09:01:00+00:00', callback_at: null, phone: '79001110001' },
|
||||
nomera: [
|
||||
nomer({ itog_id: 1, phone: '79001110001' }),
|
||||
nomer({ itog_id: 2, phone: '79001110002', outcome: 'no_answer', nedozvonov: 12, razgovory: [] }),
|
||||
],
|
||||
kampaniy_skryto: 0,
|
||||
});
|
||||
|
||||
expect(w.find('[data-testid="obzvon-itog"]').text()).toContain('Переведено');
|
||||
expect(w.find('[data-testid="obzvon-itog"]').text()).not.toContain('Не дозвонились');
|
||||
});
|
||||
|
||||
it('два номера показаны ДВУМЯ записями и различимы телефоном', () => {
|
||||
const w = smontirovat({
|
||||
itog: null,
|
||||
nomera: [
|
||||
nomer({ itog_id: 1, phone: '79001110001', razgovory: [razgovor({ id: 1 })] }),
|
||||
nomer({ itog_id: 2, phone: '79001110002', campaign_id: 2, razgovory: [razgovor({ id: 2 })] }),
|
||||
],
|
||||
kampaniy_skryto: 0,
|
||||
});
|
||||
|
||||
const bloki = w.findAll('[data-testid="obzvon-nomer"]');
|
||||
expect(bloki).toHaveLength(2);
|
||||
expect(bloki[0].text()).toContain('79001110001');
|
||||
expect(bloki[1].text()).toContain('79001110002');
|
||||
expect(bloki[1].text()).toContain('список №2');
|
||||
});
|
||||
|
||||
it('стёртая по сроку запись отличима от «записи нет»', () => {
|
||||
const w = smontirovat({
|
||||
itog: null,
|
||||
nomera: [
|
||||
nomer({
|
||||
razgovory: [
|
||||
razgovor({ id: 1, zapis: 'styorta' }),
|
||||
razgovor({ id: 2, zapis: 'ne_bylo' }),
|
||||
razgovor({ id: 3, zapis: 'est' }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
kampaniy_skryto: 0,
|
||||
});
|
||||
|
||||
const stroki = w.findAll('[data-testid="obzvon-razgovor"]').map((s) => s.text());
|
||||
expect(stroki[0]).toContain('удалена по сроку');
|
||||
expect(stroki[1]).toContain('Записи нет');
|
||||
expect(stroki[2]).toContain('Запись есть');
|
||||
});
|
||||
|
||||
it('перезвонил сам — строка ленты помечена как входящая', () => {
|
||||
const w = smontirovat({
|
||||
itog: null,
|
||||
nomera: [nomer({ razgovory: [razgovor({ perezvonil_sam: true })] })],
|
||||
kampaniy_skryto: 0,
|
||||
});
|
||||
|
||||
expect(w.find('[data-testid="obzvon-razgovor"]').text()).toContain('перезвонил сам');
|
||||
});
|
||||
|
||||
it('исход «перезвонить» показывает обещанное время', () => {
|
||||
const w = smontirovat({
|
||||
itog: {
|
||||
outcome: 'callback',
|
||||
outcome_at: '2026-08-06T09:01:00+00:00',
|
||||
callback_at: '2026-08-07T06:00:00+00:00',
|
||||
phone: '79001110001',
|
||||
},
|
||||
nomera: [nomer({ outcome: 'callback', callback_at: '2026-08-07T06:00:00+00:00' })],
|
||||
kampaniy_skryto: 0,
|
||||
});
|
||||
|
||||
const text = w.find('[data-testid="obzvon-itog"]').text();
|
||||
expect(text).toContain('Перезвонить');
|
||||
// Московское время: 06:00 UTC = 09:00 МСК.
|
||||
expect(text).toContain('09:00');
|
||||
});
|
||||
|
||||
it('скрытые настройкой прошлые списки названы вслух, а не пропали молча', () => {
|
||||
const w = smontirovat({
|
||||
itog: null,
|
||||
nomera: [nomer()],
|
||||
kampaniy_skryto: 2,
|
||||
});
|
||||
|
||||
expect(w.find('[data-testid="obzvon-skryto"]').text()).toContain('2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* З-2.4 Т67 — пометки касаний в строке списка сделок.
|
||||
*
|
||||
* 🔴 ОТ КАКОГО ВРЕДА:
|
||||
* 1. пометка названа словом, которое на этом экране уже занято: «Звонки» и
|
||||
* «СМС» в колонке «Источник» означают, ОТКУДА пришёл лид. Одинаковые
|
||||
* слова человек прочтёт задом наперёд — решит, что лид пришёл со звонка,
|
||||
* хотя это мы ему звонили;
|
||||
* 2. видно только последнее касание, а не вся история;
|
||||
* 3. у сделки без касаний в строке появился мусор.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import DealsTable from '../../resources/js/components/deals/DealsTable.vue';
|
||||
import type { MockDeal } from '../../resources/js/composables/mockDeals';
|
||||
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1440 });
|
||||
|
||||
const vuetify = createVuetify();
|
||||
|
||||
function sdelka(over: Partial<MockDeal> = {}): MockDeal {
|
||||
return {
|
||||
id: 1,
|
||||
name: '+7 (916) 100-00-01',
|
||||
phone: '+7 (916) 100-00-01',
|
||||
statusSlug: 'new',
|
||||
project: 'Окна',
|
||||
manager: { initials: 'AD', name: 'Admin' },
|
||||
cost: 0,
|
||||
receivedMinutesAgo: 5,
|
||||
signalType: 'call',
|
||||
city: 'Москва',
|
||||
comment: null,
|
||||
receivedAt: '2026-08-06T09:00:00+00:00',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function smontirovat(deals: MockDeal[]) {
|
||||
return mount(DealsTable, {
|
||||
props: { deals, selectedIds: [], statusBySlug: new Map() },
|
||||
global: { plugins: [vuetify] },
|
||||
});
|
||||
}
|
||||
|
||||
describe('пометки касаний в списке сделок', () => {
|
||||
it('колонка «Касания» есть в шапке', () => {
|
||||
const w = smontirovat([sdelka()]);
|
||||
const headers = w.findAll('thead th').map((h) => h.text());
|
||||
expect(headers.some((h) => h.includes('Касания'))).toBe(true);
|
||||
});
|
||||
|
||||
it('🔴 пометки НЕ называются словами колонки «Источник»', () => {
|
||||
const w = smontirovat([sdelka({ touches: { obzvon: 3, sms: 2 } })]);
|
||||
const text = w.find('[data-testid="deal-touches"]').text();
|
||||
|
||||
expect(text).toContain('Обзвон');
|
||||
expect(text).toContain('Рассылка');
|
||||
// Занятые слова: «Звонки» — источник лида, «СМС» — тоже источник.
|
||||
expect(text).not.toContain('Звонки');
|
||||
expect(text).not.toContain('СМС');
|
||||
});
|
||||
|
||||
it('видна ВСЯ история: три звонка и две рассылки, а не последнее действие', () => {
|
||||
const w = smontirovat([sdelka({ touches: { obzvon: 3, sms: 2 } })]);
|
||||
const text = w.find('[data-testid="deal-touches"]').text();
|
||||
|
||||
expect(text).toContain('3');
|
||||
expect(text).toContain('2');
|
||||
});
|
||||
|
||||
it('сделка без касаний — пометок нет вовсе', () => {
|
||||
const w = smontirovat([sdelka({ touches: {} })]);
|
||||
expect(w.find('[data-testid="deal-touches"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('сделка старого ответа без поля touches не ломается', () => {
|
||||
const w = smontirovat([sdelka()]);
|
||||
expect(w.find('[data-testid="deal-touches"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('порядок пометок постоянный: сперва обзвон, потом рассылка', () => {
|
||||
const w = smontirovat([sdelka({ touches: { sms: 1, obzvon: 1 } })]);
|
||||
const chips = w.findAll('[data-testid="deal-touches"] .touch-chip').map((c) => c.text());
|
||||
expect(chips[0]).toContain('Обзвон');
|
||||
expect(chips[1]).toContain('Рассылка');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user