WIP чекпойнт: lead-region/supplier бэкенд + фронт-редизайн + Pint + тесты

92 файла одной пачкой. Исключены чужие зоны: CLAUDE.md, .claude/settings.json, docs/observer/.pii-counters.json.
gitleaks staged: no leaks found. Не верифицировано тестами - сохранение труда в историю.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-17 05:17:12 +03:00
parent 989afff5ad
commit ae286b9a0d
92 changed files with 823 additions and 734 deletions
@@ -6,6 +6,7 @@ namespace App\Console\Commands;
use App\Support\RussianRegions;
use Illuminate\Console\Command;
use Illuminate\Database\Connection;
use Illuminate\Support\Facades\DB;
use OpenSpout\Reader\XLSX\Reader as XlsxReader;
@@ -135,7 +136,7 @@ class PhoneRangesImportCommand extends Command
'error' => trim('dry-run (swap не выполнен). '.$unmatchedNote),
'completed_at' => now(),
]);
$this->info('dry-run: '.count($rows)." строк в phone_ranges_staging, swap не выполнен.");
$this->info('dry-run: '.count($rows).' строк в phone_ranges_staging, swap не выполнен.');
if ($unmatchedNote !== '') {
$this->warn($unmatchedNote);
}
@@ -171,7 +172,7 @@ class PhoneRangesImportCommand extends Command
}
/**
* @return list<string>|null Список файлов или null при ошибке валидации опций.
* @return list<string>|null Список файлов или null при ошибке валидации опций.
*/
private function resolveFiles(): ?array
{
@@ -294,7 +295,7 @@ class PhoneRangesImportCommand extends Command
*/
private function parseXlsx(string $path): array
{
$reader = new XlsxReader();
$reader = new XlsxReader;
$reader->open($path);
$out = [];
@@ -430,7 +431,7 @@ class PhoneRangesImportCommand extends Command
* SET ROLE crm_migrator для корректного ownership на проде; на dev/test роль
* отсутствует RESET и работаем как superuser (зеркало миграционного паттерна).
*/
private function elevate(\Illuminate\Database\Connection $c): void
private function elevate(Connection $c): void
{
try {
$c->statement('SET ROLE crm_migrator');
@@ -28,7 +28,7 @@ final class SnapshotBackfillCommand extends Command
$weekdayBit = 1 << ($date->isoWeekday() - 1);
$count = DB::connection('pgsql_supplier')->transaction(function () use ($dateStr, $weekdayBit) {
return DB::connection('pgsql_supplier')->insert(<<<SQL
return DB::connection('pgsql_supplier')->insert(<<<'SQL'
INSERT INTO project_routing_snapshots (
snapshot_date, project_id, tenant_id,
daily_limit, delivery_days_mask, regions,
@@ -47,7 +47,7 @@ final class SnapshotRebuildCommand extends Command
->where('snapshot_date', $dateStr)
->delete();
$inserted = DB::connection('pgsql_supplier')->insert(<<<SQL
$inserted = DB::connection('pgsql_supplier')->insert(<<<'SQL'
INSERT INTO project_routing_snapshots (
snapshot_date, project_id, tenant_id,
daily_limit, delivery_days_mask, regions,
+1 -1
View File
@@ -605,7 +605,7 @@ class RouteSupplierLeadJob implements ShouldQueue
}
/**
* @return list<int> '{82,83}' [82,83]; '{}'/'' []
* @return list<int> '{82,83}' [82,83]; '{}'/'' []
*/
private function parseSubjectCodes(string $regionsLiteral): array
{
+3 -2
View File
@@ -20,7 +20,7 @@ use Illuminate\Support\Facades\Log;
*/
final class SnapshotProjectRoutingJob implements ShouldQueue
{
use Dispatchable, Queueable, InteractsWithQueue, SerializesModels;
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public const DB_CONNECTION = 'pgsql_supplier'; // BYPASSRLS
@@ -38,10 +38,11 @@ final class SnapshotProjectRoutingJob implements ShouldQueue
->exists();
if ($exists) {
Log::info('snapshot.already_exists', ['date' => $snapshotDate]);
return;
}
$count = DB::connection(self::DB_CONNECTION)->insert(<<<SQL
$count = DB::connection(self::DB_CONNECTION)->insert(<<<'SQL'
INSERT INTO project_routing_snapshots (
snapshot_date, project_id, tenant_id,
daily_limit, delivery_days_mask, regions,
+5 -3
View File
@@ -6,9 +6,11 @@ namespace App\Jobs\Supplier;
use App\Jobs\RouteSupplierLeadJob;
use App\Mail\CsvDriftAlertMail;
use App\Mail\TenantBusinessDriftAlertMail;
use App\Models\SupplierLead;
use App\Services\Supplier\SupplierCsvParser;
use App\Services\Supplier\SupplierPortalClient;
use Carbon\CarbonInterface;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Cache\LockProvider;
use Illuminate\Contracts\Mail\Mailer;
@@ -274,8 +276,8 @@ final class CsvReconcileJob implements ShouldQueue
private function detectAndAlertBusinessDrift(
Mailer $mailer,
\Carbon\CarbonInterface $windowStart,
\Carbon\CarbonInterface $windowEnd,
CarbonInterface $windowStart,
CarbonInterface $windowEnd,
): void {
$from = $windowStart->toDateString();
$to = $windowEnd->toDateString();
@@ -300,7 +302,7 @@ final class CsvReconcileJob implements ShouldQueue
}
$mailer->to((string) config('services.supplier.alert_email'))
->send(new \App\Mail\TenantBusinessDriftAlertMail(
->send(new TenantBusinessDriftAlertMail(
tenantId: (int) $row->tenant_id,
snapshotDate: (string) $row->snapshot_date,
expected: $expected,
+1 -1
View File
@@ -152,7 +152,7 @@ class LeadRegionResolver
}
/**
* @return array<string, mixed> сырой ответ DaData с маскированным телефоном (§7.1)
* @return array<string, mixed> сырой ответ DaData с маскированным телефоном (§7.1)
*/
private function maskResponse(DaDataPhoneResponse $response): array
{
@@ -4,8 +4,8 @@ declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
@@ -5,7 +5,8 @@ declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration {
return new class extends Migration
{
public function up(): void
{
// SET ROLE crm_migrator для прода (postgres superuser может SET ROLE).
@@ -13,10 +14,10 @@ return new class extends Migration {
try {
DB::statement('SET ROLE crm_migrator');
$canCreate = DB::selectOne("SELECT has_schema_privilege('crm_migrator', 'public', 'CREATE') AS ok");
if (!$canCreate || !$canCreate->ok) {
if (! $canCreate || ! $canCreate->ok) {
DB::statement('RESET ROLE');
}
} catch (\Throwable) {
} catch (Throwable) {
// На окружениях без роли — продолжаем как postgres superuser.
}
@@ -83,10 +84,10 @@ return new class extends Migration {
try {
DB::statement('SET ROLE crm_migrator');
$canCreate = DB::selectOne("SELECT has_schema_privilege('crm_migrator', 'public', 'CREATE') AS ok");
if (!$canCreate || !$canCreate->ok) {
if (! $canCreate || ! $canCreate->ok) {
DB::statement('RESET ROLE');
}
} catch (\Throwable) {
} catch (Throwable) {
// На окружениях без роли — продолжаем как postgres superuser.
}
DB::statement('DROP TABLE IF EXISTS project_routing_snapshots CASCADE');
@@ -5,7 +5,8 @@ declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration {
return new class extends Migration
{
public function up(): void
{
// SET ROLE crm_migrator на проде (postgres superuser может SET ROLE).
@@ -14,10 +15,10 @@ return new class extends Migration {
try {
DB::statement('SET ROLE crm_migrator');
$canCreate = DB::selectOne("SELECT has_schema_privilege('crm_migrator', 'public', 'CREATE') AS ok");
if (!$canCreate || !$canCreate->ok) {
if (! $canCreate || ! $canCreate->ok) {
DB::statement('RESET ROLE');
}
} catch (\Throwable) {
} catch (Throwable) {
// окружение без роли — продолжаем как superuser
}
@@ -139,10 +140,10 @@ return new class extends Migration {
try {
DB::statement('SET ROLE crm_migrator');
$canCreate = DB::selectOne("SELECT has_schema_privilege('crm_migrator', 'public', 'CREATE') AS ok");
if (!$canCreate || !$canCreate->ok) {
if (! $canCreate || ! $canCreate->ok) {
DB::statement('RESET ROLE');
}
} catch (\Throwable) {
} catch (Throwable) {
// окружение без роли — продолжаем как superuser
}
+5 -8
View File
@@ -351,10 +351,10 @@ export async function updateTenantStatus(
reason: string,
): Promise<{ id: number; status: string }> {
await ensureCsrfCookie();
const { data } = await apiClient.patch<{ id: number; status: string }>(
`/api/admin/billing/tenants/${id}/status`,
{ status, reason },
);
const { data } = await apiClient.patch<{ id: number; status: string }>(`/api/admin/billing/tenants/${id}/status`, {
status,
reason,
});
return data;
}
@@ -567,9 +567,6 @@ export async function createPdSubjectRequest(payload: CreatePdRequestPayload): P
export async function executePdErasure(id: number, adminUserId?: number): Promise<EraseSubjectResult> {
await ensureCsrfCookie();
const payload = adminUserId !== undefined ? { admin_user_id: adminUserId } : {};
const { data } = await apiClient.post<EraseSubjectResult>(
`/api/admin/pd-subject-requests/${id}/erase`,
payload,
);
const { data } = await apiClient.post<EraseSubjectResult>(`/api/admin/pd-subject-requests/${id}/erase`, payload);
return data;
}
@@ -75,11 +75,7 @@ function close(): void {
</script>
<template>
<v-dialog
:model-value="modelValue"
max-width="460"
@update:model-value="emit('update:modelValue', $event)"
>
<v-dialog :model-value="modelValue" max-width="460" @update:model-value="emit('update:modelValue', $event)">
<v-card>
<v-card-title class="text-h6">Изменить баланс</v-card-title>
<v-card-subtitle>{{ tenantName }}</v-card-subtitle>
@@ -110,11 +106,13 @@ function close(): void {
/>
<div v-if="delta !== ''" class="preview mt-3 text-body-2">
было <span class="num">{{ currentBalanceRub.toFixed(2) }} </span>
станет <span class="num">{{ targetNormalized }} </span>
(<span class="num" :class="Number(delta) < 0 ? 'text-error' : 'text-success'">
{{ Number(delta) > 0 ? '+' : '' }}{{ delta }}
</span>)
было <span class="num">{{ currentBalanceRub.toFixed(2) }} </span> станет
<span class="num">{{ targetNormalized }} </span> (<span
class="num"
:class="Number(delta) < 0 ? 'text-error' : 'text-success'"
>
{{ Number(delta) > 0 ? '+' : '' }}{{ delta }} </span
>)
</div>
<v-alert v-if="errorMsg" type="error" variant="tonal" density="compact" class="mt-3">
@@ -36,9 +36,7 @@ const daysLabel = computed(() => (Number.isFinite(daysLeft.value) ? daysLeft.val
<div class="balance-capacity text-body-2" :class="statusClass" data-testid="balance-capacity-indicator">
<div>Баланс: {{ balanceRub }} = до {{ capacityLeads }} лидов по тарифу</div>
<div>Проекты заказывают: {{ requiredLeadsPerDay }} лидов в день</div>
<div v-if="statusClass === 'capacity-insufficient'" class="capacity-note">
Не хватает пополните счёт
</div>
<div v-if="statusClass === 'capacity-insufficient'" class="capacity-note"> Не хватает пополните счёт</div>
<div v-else-if="statusClass === 'capacity-warning'" class="capacity-note">
Хватит на ~{{ daysLabel }} дн. скоро потребуется пополнение
</div>
@@ -34,12 +34,7 @@ const walletText = computed(() => new Intl.NumberFormat('ru-RU').format(props.wa
</div>
<div class="wallet-foot mt-3">мин. пополнение <strong>100 </strong></div>
<div class="wallet-actions mt-3">
<v-btn
color="primary"
variant="flat"
prepend-icon="mdi-plus"
size="small"
@click="$emit('topup')"
<v-btn color="primary" variant="flat" prepend-icon="mdi-plus" size="small" @click="$emit('topup')"
>Пополнить</v-btn
>
<v-tooltip text="Автопополнение будет доступно после подключения платёжного шлюза.">
@@ -64,7 +59,8 @@ const walletText = computed(() => new Intl.NumberFormat('ru-RU').format(props.wa
<span class="num"> {{ affordableLeads }}</span>
<span class="ru-text">&nbsp;лидов</span>
</div>
<div class="wallet-foot mt-2">сейчас по {{ currentTierPriceRub }} /лид
<div class="wallet-foot mt-2">
сейчас по {{ currentTierPriceRub }} /лид
<v-tooltip text="Точный расчёт по текущим ценам. Меняется при переходе ступеней.">
<template #activator="{ props: tipProps }">
<v-icon v-bind="tipProps" size="14" class="ml-1">mdi-information-outline</v-icon>
@@ -32,11 +32,10 @@ const hasDeficit = computed(() => (props.deficitLeads ?? 0) > 0);
<div class="text-subtitle-2 font-weight-bold">Приём лидов приостановлен</div>
<div class="text-body-2 mb-2">
Не хватает баланса на дневной заказ.<span v-if="hasDeficit">
Нужно ещё {{ deficitRub }} (или сократи лимиты на {{ deficitLeads }} лидов).</span>
Нужно ещё {{ deficitRub }} (или сократи лимиты на {{ deficitLeads }} лидов).</span
>
</div>
<RouterLink to="/billing" data-testid="banner-topup-link" class="banner-link">
Пополнить счёт
</RouterLink>
<RouterLink to="/billing" data-testid="banner-topup-link" class="banner-link"> Пополнить счёт </RouterLink>
<RouterLink to="/projects" data-testid="banner-projects-link" class="banner-link ml-4">
Перейти к проектам
</RouterLink>
@@ -72,12 +72,7 @@ defineExpose({ load, invoices });
<span class="sub">{{ statusLabel(inv.status) }}</span>
</span>
<span class="inv-amount num">{{ formatPlain(Number(inv.amount_total)) }}</span>
<v-btn
variant="text"
size="small"
prepend-icon="mdi-file-pdf-box"
:disabled="!inv.has_pdf"
>
<v-btn variant="text" size="small" prepend-icon="mdi-file-pdf-box" :disabled="!inv.has_pdf">
PDF
</v-btn>
</li>
@@ -16,7 +16,9 @@ const tiers = [
<Story title="Billing/TierPricesPanel">
<Variant title="Текущая ступень: 1"><TierPricesPanel :tiers="tiers" :current-tier-no="1" /></Variant>
<Variant title="Текущая ступень: 3"><TierPricesPanel :tiers="tiers" :current-tier-no="3" /></Variant>
<Variant title="Текущая ступень: 7 (всё свыше)"><TierPricesPanel :tiers="tiers" :current-tier-no="7" /></Variant>
<Variant title="Текущая ступень: 7 (всё свыше)"
><TierPricesPanel :tiers="tiers" :current-tier-no="7"
/></Variant>
<Variant title="Без current_tier"><TierPricesPanel :tiers="tiers" :current-tier-no="null" /></Variant>
</Story>
</template>
@@ -45,12 +45,9 @@ function rangeText(idx: number): string {
<span class="tier-no num">{{ t.tier_no }}</span>
<span class="tier-range">{{ rangeText(i) }} лидов</span>
<span class="tier-price num">{{ t.price_rub }} </span>
<v-chip
v-if="t.tier_no === currentTierNo"
size="x-small"
color="primary"
variant="elevated"
>вы здесь</v-chip>
<v-chip v-if="t.tier_no === currentTierNo" size="x-small" color="primary" variant="elevated"
>вы здесь</v-chip
>
</li>
</ul>
</template>
@@ -26,9 +26,7 @@ const amountError = computed<string | null>(() => {
return null;
});
const canSubmit = computed(
() => Number.isFinite(amount.value) && amountError.value === null && !submitting.value,
);
const canSubmit = computed(() => Number.isFinite(amount.value) && amountError.value === null && !submitting.value);
// Сброс состояния при каждом открытии диалога (паттерн ReminderDialog/
// NewDealDialog) — нет префилла прошлой суммы и нет всплытия устаревшей ошибки.
@@ -85,20 +83,13 @@ defineExpose({ amount, submit, canSubmit, errorMsg });
/>
<div class="presets mb-2">
<v-chip
v-for="p in PRESETS"
:key="p"
size="small"
variant="outlined"
@click="setPreset(p)"
>
<v-chip v-for="p in PRESETS" :key="p" size="small" variant="outlined" @click="setPreset(p)">
{{ new Intl.NumberFormat('ru-RU').format(p) }}
</v-chip>
</div>
<v-alert type="info" variant="tonal" density="compact" class="mt-2">
Платёжный шлюз подключается после регистрации юр. лица на текущем этапе баланс
пополняется сразу.
Платёжный шлюз подключается после регистрации юр. лица на текущем этапе баланс пополняется сразу.
</v-alert>
<v-alert v-if="errorMsg" type="error" variant="tonal" density="compact" class="mt-3" role="alert">
@@ -99,20 +99,8 @@ defineExpose({ load, refresh, changeTab, activeTab, total, rows });
<v-card variant="outlined" class="mt-4 panel">
<div class="panel-h pa-4">
<h2 class="text-h6 panel-title ma-0">История транзакций</h2>
<v-btn-toggle
:model-value="activeTab"
mandatory
color="primary"
density="comfortable"
variant="text"
>
<v-btn
v-for="tab in TABS"
:key="tab.id"
:value="tab.id"
size="small"
@click="changeTab(tab.id)"
>
<v-btn-toggle :model-value="activeTab" mandatory color="primary" density="comfortable" variant="text">
<v-btn v-for="tab in TABS" :key="tab.id" :value="tab.id" size="small" @click="changeTab(tab.id)">
{{ tab.label }}
</v-btn>
</v-btn-toggle>
@@ -29,7 +29,9 @@ const greeting = computed(() => {
<template>
<header class="page-head">
<div>
<h1 class="text-h4 mb-2 page-greet">{{ greeting }}, <em class="text-primary">{{ firstName }}</em></h1>
<h1 class="text-h4 mb-2 page-greet">
{{ greeting }}, <em class="text-primary">{{ firstName }}</em>
</h1>
<div class="page-meta text-body-2 text-medium-emphasis">
<span><span class="num text-primary">+3</span> новых лида с утра</span>
<span class="sep">·</span>
@@ -179,8 +179,16 @@ watch(
);
defineExpose({
events, eventsLoading, eventsFetchError, loadEvents,
commentDraft, commentSaving, commentSaveError, commentToastOpen, commentToastText, saveComment,
events,
eventsLoading,
eventsFetchError,
loadEvents,
commentDraft,
commentSaving,
commentSaveError,
commentToastOpen,
commentToastText,
saveComment,
});
</script>
@@ -43,14 +43,7 @@ function close() {
@status-changed="(s: string) => emit('status-changed', s)"
/>
</aside>
<v-navigation-drawer
v-else
v-model="drawerOpen"
location="right"
temporary
:width="480"
class="deal-drawer"
>
<v-navigation-drawer v-else v-model="drawerOpen" location="right" temporary :width="480" class="deal-drawer">
<DealDetailBody
:deal="deal"
:tenant-id="tenantId"
@@ -55,7 +55,11 @@ function formatRelative(minutes: number): string {
data-testid="status-chip-trigger"
size="small"
variant="tonal"
:style="{ color: status.colorHex, borderColor: status.colorHex, cursor: (allStatuses?.length ?? 0) > 0 ? 'pointer' : 'default' }"
:style="{
color: status.colorHex,
borderColor: status.colorHex,
cursor: (allStatuses?.length ?? 0) > 0 ? 'pointer' : 'default',
}"
>
<span class="status-dot" :style="{ background: status.colorHex }" />
{{ status.nameRu }}
@@ -29,7 +29,9 @@ defineEmits<{
data-testid="bulk-bar"
>
<div class="bulk-bar-inner">
<span class="bulk-count">Выбрано <span class="num">{{ selectedCount }}</span></span>
<span class="bulk-count"
>Выбрано <span class="num">{{ selectedCount }}</span></span
>
<v-spacer />
<v-menu
:model-value="statusMenuOpen"
@@ -24,8 +24,7 @@ defineEmits<{
'clear-filters': [];
}>();
const hasActiveFilter = () =>
props.filterStatus !== null || props.filterProject !== null || props.filterCity !== null;
const hasActiveFilter = () => props.filterStatus !== null || props.filterProject !== null || props.filterCity !== null;
</script>
<template>
@@ -34,7 +34,11 @@ function formatDateTime(iso: string | null | undefined): string {
if (!iso) return '—';
const d = new Date(iso);
return new Intl.DateTimeFormat('ru-RU', {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(d);
}
@@ -36,9 +36,7 @@ const dialogOpen = computed({
set: (v: boolean) => emit('update:modelValue', v),
});
const allMapped = computed(
() => props.statuses.length > 0 && props.statuses.every((s) => !!selection[s.status_ru]),
);
const allMapped = computed(() => props.statuses.length > 0 && props.statuses.every((s) => !!selection[s.status_ru]));
async function save(): Promise<void> {
if (!allMapped.value) {
@@ -69,19 +67,13 @@ defineExpose({ selection, save });
<v-card-title class="text-h6">Маппинг неизвестных статусов</v-card-title>
<v-card-text>
<p class="text-body-2 text-medium-emphasis mb-4">
Эти статусы из CSV не входят в стандартную воронку. Выберите
соответствие повторный импорт применит маппинг автоматически.
Эти статусы из CSV не входят в стандартную воронку. Выберите соответствие повторный импорт
применит маппинг автоматически.
</p>
<div
v-for="status in statuses"
:key="status.id"
class="d-flex align-center ga-3 mb-3"
>
<div v-for="status in statuses" :key="status.id" class="d-flex align-center ga-3 mb-3">
<div class="flex-grow-1">
<strong>{{ status.status_ru }}</strong>
<span class="text-caption text-medium-emphasis ml-2">
({{ status.occurrences }} шт.)
</span>
<span class="text-caption text-medium-emphasis ml-2"> ({{ status.occurrences }} шт.) </span>
</div>
<v-select
v-model="selection[status.status_ru]"
@@ -57,9 +57,7 @@ const navGroups = computed<NavGroup[]>(() => [
},
{
eyebrow: 'Финансы',
items: [
{ title: 'Биллинг', icon: 'mdi-credit-card-outline', to: '/billing' },
],
items: [{ title: 'Биллинг', icon: 'mdi-credit-card-outline', to: '/billing' }],
},
{
eyebrow: 'Команда',
@@ -112,7 +112,12 @@ async function handleLogout(): Promise<void> {
</template>
</v-btn>
<v-menu offset="8" :close-on-content-click="false" location="bottom end" @update:model-value="repositionMenuAfterOpen">
<v-menu
offset="8"
:close-on-content-click="false"
location="bottom end"
@update:model-value="repositionMenuAfterOpen"
>
<template #activator="{ props: bellProps }">
<v-btn
v-bind="bellProps"
@@ -27,12 +27,7 @@
<v-divider vertical />
<v-btn
color="error"
prepend-icon="mdi-delete"
data-testid="bulk-delete"
@click="confirmAndRun('delete')"
>
<v-btn color="error" prepend-icon="mdi-delete" data-testid="bulk-delete" @click="confirmAndRun('delete')">
Удалить
</v-btn>
@@ -29,23 +29,18 @@ defineEmits<{
</script>
<template>
<v-dialog
:model-value="modelValue"
max-width="520"
@update:model-value="$emit('update:modelValue', $event)"
>
<v-dialog :model-value="modelValue" max-width="520" @update:model-value="$emit('update:modelValue', $event)">
<v-card v-if="payload" data-testid="overload-dialog">
<v-card-title>Лимит превышает баланс</v-card-title>
<v-card-text>
<p>
У тебя {{ payload.current_balance_rub }} =
{{ payload.current_capacity_leads }} лидов по текущему тарифу.
У тебя {{ payload.current_balance_rub }} = {{ payload.current_capacity_leads }} лидов по текущему
тарифу.
</p>
<p>После сохранения нужно {{ payload.would_be_required_leads }} лидов.</p>
<p class="font-weight-medium">Не хватает: {{ payload.deficit_leads }} лидов.</p>
<p class="text-medium-emphasis mt-2">
Чтобы проект начал работать пополни счёт, поставь его лимит 0
или уменьши лимиты других проектов.
Чтобы проект начал работать пополни счёт, поставь его лимит 0 или уменьши лимиты других проектов.
</p>
</v-card-text>
<v-card-actions>
@@ -4,8 +4,8 @@
<v-card-title>Регионы для {{ count }} проектов</v-card-title>
<v-card-text>
<p class="text-caption text-medium-emphasis mb-4">
Изменения применяются к каждому из {{ count }} выбранных проектов: выбранные субъекты
добавляются к их регионам или убираются из них.
Изменения применяются к каждому из {{ count }} выбранных проектов: выбранные субъекты добавляются к
их регионам или убираются из них.
</p>
<div class="mb-2">
+1 -1
View File
@@ -19,4 +19,4 @@ const CHANNEL_PREFIX_RE = /^B[123]_/i;
export function stripChannelPrefix(name: string | null | undefined): string {
if (!name) return '';
return name.replace(CHANNEL_PREFIX_RE, '');
}
}
+91 -91
View File
@@ -1,6 +1,6 @@
export interface Region {
code: number; // 1..89, sequential по конституционному порядку (Art. 65)
name: string; // официальное название субъекта
code: number; // 1..89, sequential по конституционному порядку (Art. 65)
name: string; // официальное название субъекта
federalDistrict: number; // 1..8 (см. FEDERAL_DISTRICT_NAMES)
}
@@ -9,102 +9,102 @@ export interface Region {
// 3 города фед.знач. (82..84) → 1 АО Еврейская (85) → 4 АО (86..89).
// Sentinel code:0 = "Вся РФ" (UI hint, в БД хранится как regions=[]).
export const REGIONS: Region[] = [
{ code: 0, name: 'Вся РФ', federalDistrict: 0 },
{ code: 0, name: 'Вся РФ', federalDistrict: 0 },
// 24 республики
{ code: 1, name: 'Республика Адыгея', federalDistrict: 3 },
{ code: 2, name: 'Республика Алтай', federalDistrict: 7 },
{ code: 3, name: 'Республика Башкортостан', federalDistrict: 5 },
{ code: 4, name: 'Республика Бурятия', federalDistrict: 8 },
{ code: 5, name: 'Республика Дагестан', federalDistrict: 4 },
{ code: 6, name: 'Донецкая Народная Республика', federalDistrict: 3 },
{ code: 7, name: 'Республика Ингушетия', federalDistrict: 4 },
{ code: 8, name: 'Кабардино-Балкарская Республика', federalDistrict: 4 },
{ code: 9, name: 'Республика Калмыкия', federalDistrict: 3 },
{ code: 10, name: 'Карачаево-Черкесская Республика', federalDistrict: 4 },
{ code: 11, name: 'Республика Карелия', federalDistrict: 2 },
{ code: 12, name: 'Республика Коми', federalDistrict: 2 },
{ code: 13, name: 'Республика Крым', federalDistrict: 3 },
{ code: 14, name: 'Луганская Народная Республика', federalDistrict: 3 },
{ code: 15, name: 'Республика Марий Эл', federalDistrict: 5 },
{ code: 16, name: 'Республика Мордовия', federalDistrict: 5 },
{ code: 17, name: 'Республика Саха (Якутия)', federalDistrict: 8 },
{ code: 18, name: 'Республика Северная Осетия — Алания', federalDistrict: 4 },
{ code: 19, name: 'Республика Татарстан', federalDistrict: 5 },
{ code: 20, name: 'Республика Тыва', federalDistrict: 7 },
{ code: 21, name: 'Удмуртская Республика', federalDistrict: 5 },
{ code: 22, name: 'Республика Хакасия', federalDistrict: 7 },
{ code: 23, name: 'Чеченская Республика', federalDistrict: 4 },
{ code: 24, name: 'Чувашская Республика', federalDistrict: 5 },
{ code: 1, name: 'Республика Адыгея', federalDistrict: 3 },
{ code: 2, name: 'Республика Алтай', federalDistrict: 7 },
{ code: 3, name: 'Республика Башкортостан', federalDistrict: 5 },
{ code: 4, name: 'Республика Бурятия', federalDistrict: 8 },
{ code: 5, name: 'Республика Дагестан', federalDistrict: 4 },
{ code: 6, name: 'Донецкая Народная Республика', federalDistrict: 3 },
{ code: 7, name: 'Республика Ингушетия', federalDistrict: 4 },
{ code: 8, name: 'Кабардино-Балкарская Республика', federalDistrict: 4 },
{ code: 9, name: 'Республика Калмыкия', federalDistrict: 3 },
{ code: 10, name: 'Карачаево-Черкесская Республика', federalDistrict: 4 },
{ code: 11, name: 'Республика Карелия', federalDistrict: 2 },
{ code: 12, name: 'Республика Коми', federalDistrict: 2 },
{ code: 13, name: 'Республика Крым', federalDistrict: 3 },
{ code: 14, name: 'Луганская Народная Республика', federalDistrict: 3 },
{ code: 15, name: 'Республика Марий Эл', federalDistrict: 5 },
{ code: 16, name: 'Республика Мордовия', federalDistrict: 5 },
{ code: 17, name: 'Республика Саха (Якутия)', federalDistrict: 8 },
{ code: 18, name: 'Республика Северная Осетия — Алания', federalDistrict: 4 },
{ code: 19, name: 'Республика Татарстан', federalDistrict: 5 },
{ code: 20, name: 'Республика Тыва', federalDistrict: 7 },
{ code: 21, name: 'Удмуртская Республика', federalDistrict: 5 },
{ code: 22, name: 'Республика Хакасия', federalDistrict: 7 },
{ code: 23, name: 'Чеченская Республика', federalDistrict: 4 },
{ code: 24, name: 'Чувашская Республика', federalDistrict: 5 },
// 9 краёв
{ code: 25, name: 'Алтайский край', federalDistrict: 7 },
{ code: 26, name: 'Забайкальский край', federalDistrict: 8 },
{ code: 27, name: 'Камчатский край', federalDistrict: 8 },
{ code: 28, name: 'Краснодарский край', federalDistrict: 3 },
{ code: 29, name: 'Красноярский край', federalDistrict: 7 },
{ code: 30, name: 'Пермский край', federalDistrict: 5 },
{ code: 31, name: 'Приморский край', federalDistrict: 8 },
{ code: 32, name: 'Ставропольский край', federalDistrict: 4 },
{ code: 33, name: 'Хабаровский край', federalDistrict: 8 },
{ code: 25, name: 'Алтайский край', federalDistrict: 7 },
{ code: 26, name: 'Забайкальский край', federalDistrict: 8 },
{ code: 27, name: 'Камчатский край', federalDistrict: 8 },
{ code: 28, name: 'Краснодарский край', federalDistrict: 3 },
{ code: 29, name: 'Красноярский край', federalDistrict: 7 },
{ code: 30, name: 'Пермский край', federalDistrict: 5 },
{ code: 31, name: 'Приморский край', federalDistrict: 8 },
{ code: 32, name: 'Ставропольский край', federalDistrict: 4 },
{ code: 33, name: 'Хабаровский край', federalDistrict: 8 },
// 48 областей
{ code: 34, name: 'Амурская область', federalDistrict: 8 },
{ code: 35, name: 'Архангельская область', federalDistrict: 2 },
{ code: 36, name: 'Астраханская область', federalDistrict: 3 },
{ code: 37, name: 'Белгородская область', federalDistrict: 1 },
{ code: 38, name: 'Брянская область', federalDistrict: 1 },
{ code: 39, name: 'Владимирская область', federalDistrict: 1 },
{ code: 40, name: 'Волгоградская область', federalDistrict: 3 },
{ code: 41, name: 'Вологодская область', federalDistrict: 2 },
{ code: 42, name: 'Воронежская область', federalDistrict: 1 },
{ code: 43, name: 'Запорожская область', federalDistrict: 3 },
{ code: 44, name: 'Ивановская область', federalDistrict: 1 },
{ code: 45, name: 'Иркутская область', federalDistrict: 7 },
{ code: 46, name: 'Калининградская область', federalDistrict: 2 },
{ code: 47, name: 'Калужская область', federalDistrict: 1 },
{ code: 48, name: 'Кемеровская область', federalDistrict: 7 },
{ code: 49, name: 'Кировская область', federalDistrict: 5 },
{ code: 50, name: 'Костромская область', federalDistrict: 1 },
{ code: 51, name: 'Курганская область', federalDistrict: 6 },
{ code: 52, name: 'Курская область', federalDistrict: 1 },
{ code: 53, name: 'Ленинградская область', federalDistrict: 2 },
{ code: 54, name: 'Липецкая область', federalDistrict: 1 },
{ code: 55, name: 'Магаданская область', federalDistrict: 8 },
{ code: 56, name: 'Московская область', federalDistrict: 1 },
{ code: 57, name: 'Мурманская область', federalDistrict: 2 },
{ code: 58, name: 'Нижегородская область', federalDistrict: 5 },
{ code: 59, name: 'Новгородская область', federalDistrict: 2 },
{ code: 60, name: 'Новосибирская область', federalDistrict: 7 },
{ code: 61, name: 'Омская область', federalDistrict: 7 },
{ code: 62, name: 'Оренбургская область', federalDistrict: 5 },
{ code: 63, name: 'Орловская область', federalDistrict: 1 },
{ code: 64, name: 'Пензенская область', federalDistrict: 5 },
{ code: 65, name: 'Псковская область', federalDistrict: 2 },
{ code: 66, name: 'Ростовская область', federalDistrict: 3 },
{ code: 67, name: 'Рязанская область', federalDistrict: 1 },
{ code: 68, name: 'Самарская область', federalDistrict: 5 },
{ code: 69, name: 'Саратовская область', federalDistrict: 5 },
{ code: 70, name: 'Сахалинская область', federalDistrict: 8 },
{ code: 71, name: 'Свердловская область', federalDistrict: 6 },
{ code: 72, name: 'Смоленская область', federalDistrict: 1 },
{ code: 73, name: 'Тамбовская область', federalDistrict: 1 },
{ code: 74, name: 'Тверская область', federalDistrict: 1 },
{ code: 75, name: 'Томская область', federalDistrict: 7 },
{ code: 76, name: 'Тульская область', federalDistrict: 1 },
{ code: 77, name: 'Тюменская область', federalDistrict: 6 },
{ code: 78, name: 'Ульяновская область', federalDistrict: 5 },
{ code: 79, name: 'Херсонская область', federalDistrict: 3 },
{ code: 80, name: 'Челябинская область', federalDistrict: 6 },
{ code: 81, name: 'Ярославская область', federalDistrict: 1 },
{ code: 34, name: 'Амурская область', federalDistrict: 8 },
{ code: 35, name: 'Архангельская область', federalDistrict: 2 },
{ code: 36, name: 'Астраханская область', federalDistrict: 3 },
{ code: 37, name: 'Белгородская область', federalDistrict: 1 },
{ code: 38, name: 'Брянская область', federalDistrict: 1 },
{ code: 39, name: 'Владимирская область', federalDistrict: 1 },
{ code: 40, name: 'Волгоградская область', federalDistrict: 3 },
{ code: 41, name: 'Вологодская область', federalDistrict: 2 },
{ code: 42, name: 'Воронежская область', federalDistrict: 1 },
{ code: 43, name: 'Запорожская область', federalDistrict: 3 },
{ code: 44, name: 'Ивановская область', federalDistrict: 1 },
{ code: 45, name: 'Иркутская область', federalDistrict: 7 },
{ code: 46, name: 'Калининградская область', federalDistrict: 2 },
{ code: 47, name: 'Калужская область', federalDistrict: 1 },
{ code: 48, name: 'Кемеровская область', federalDistrict: 7 },
{ code: 49, name: 'Кировская область', federalDistrict: 5 },
{ code: 50, name: 'Костромская область', federalDistrict: 1 },
{ code: 51, name: 'Курганская область', federalDistrict: 6 },
{ code: 52, name: 'Курская область', federalDistrict: 1 },
{ code: 53, name: 'Ленинградская область', federalDistrict: 2 },
{ code: 54, name: 'Липецкая область', federalDistrict: 1 },
{ code: 55, name: 'Магаданская область', federalDistrict: 8 },
{ code: 56, name: 'Московская область', federalDistrict: 1 },
{ code: 57, name: 'Мурманская область', federalDistrict: 2 },
{ code: 58, name: 'Нижегородская область', federalDistrict: 5 },
{ code: 59, name: 'Новгородская область', federalDistrict: 2 },
{ code: 60, name: 'Новосибирская область', federalDistrict: 7 },
{ code: 61, name: 'Омская область', federalDistrict: 7 },
{ code: 62, name: 'Оренбургская область', federalDistrict: 5 },
{ code: 63, name: 'Орловская область', federalDistrict: 1 },
{ code: 64, name: 'Пензенская область', federalDistrict: 5 },
{ code: 65, name: 'Псковская область', federalDistrict: 2 },
{ code: 66, name: 'Ростовская область', federalDistrict: 3 },
{ code: 67, name: 'Рязанская область', federalDistrict: 1 },
{ code: 68, name: 'Самарская область', federalDistrict: 5 },
{ code: 69, name: 'Саратовская область', federalDistrict: 5 },
{ code: 70, name: 'Сахалинская область', federalDistrict: 8 },
{ code: 71, name: 'Свердловская область', federalDistrict: 6 },
{ code: 72, name: 'Смоленская область', federalDistrict: 1 },
{ code: 73, name: 'Тамбовская область', federalDistrict: 1 },
{ code: 74, name: 'Тверская область', federalDistrict: 1 },
{ code: 75, name: 'Томская область', federalDistrict: 7 },
{ code: 76, name: 'Тульская область', federalDistrict: 1 },
{ code: 77, name: 'Тюменская область', federalDistrict: 6 },
{ code: 78, name: 'Ульяновская область', federalDistrict: 5 },
{ code: 79, name: 'Херсонская область', federalDistrict: 3 },
{ code: 80, name: 'Челябинская область', federalDistrict: 6 },
{ code: 81, name: 'Ярославская область', federalDistrict: 1 },
// 3 города федерального значения
{ code: 82, name: 'Москва', federalDistrict: 1 },
{ code: 83, name: 'Санкт-Петербург', federalDistrict: 2 },
{ code: 84, name: 'Севастополь', federalDistrict: 3 },
{ code: 82, name: 'Москва', federalDistrict: 1 },
{ code: 83, name: 'Санкт-Петербург', federalDistrict: 2 },
{ code: 84, name: 'Севастополь', federalDistrict: 3 },
// 1 автономная область
{ code: 85, name: 'Еврейская автономная область', federalDistrict: 8 },
{ code: 85, name: 'Еврейская автономная область', federalDistrict: 8 },
// 4 автономных округа
{ code: 86, name: 'Ненецкий автономный округ', federalDistrict: 2 },
{ code: 86, name: 'Ненецкий автономный округ', federalDistrict: 2 },
{ code: 87, name: 'Ханты-Мансийский автономный округ — Югра', federalDistrict: 6 },
{ code: 88, name: 'Чукотский автономный округ', federalDistrict: 8 },
{ code: 89, name: 'Ямало-Ненецкий автономный округ', federalDistrict: 6 },
{ code: 88, name: 'Чукотский автономный округ', federalDistrict: 8 },
{ code: 89, name: 'Ямало-Ненецкий автономный округ', federalDistrict: 6 },
];
export const FEDERAL_DISTRICT_NAMES: Record<number, string> = {
+2 -3
View File
@@ -146,9 +146,8 @@ const currentPageTitle = computed(() => {
data-testid="dev-auth-gap-banner"
>
DEV-режим: доступ к админке открыт без SSO-проверки middleware
<code>EnsureSaasAdmin</code> в dev пропускает все запросы. В production
требуется вход через Yandex 360 + роль <code>super_admin</code> (Б-1);
неавторизованные запросы получают 503.
<code>EnsureSaasAdmin</code> в dev пропускает все запросы. В production требуется вход через Yandex 360
+ роль <code>super_admin</code> (Б-1); неавторизованные запросы получают 503.
</v-alert>
<ImpersonationBanner />
<RouterView />
+1 -3
View File
@@ -47,9 +47,7 @@ const currentPageTitle = computed(() => {
// Сначала короткий title из sidebar-nav (Дашборд/Сделки/…), затем — route.meta.title
// для страниц вне sidebar (Напоминания, Импорт данных), и только потом fallback.
return (
navItems.value.find((i) => i.to === route.path)?.title ??
(route.meta.title as string | undefined) ??
'Страница'
navItems.value.find((i) => i.to === route.path)?.title ?? (route.meta.title as string | undefined) ?? 'Страница'
);
});
+1 -3
View File
@@ -148,9 +148,7 @@ defineExpose({ loadWallet, wallet, topupOpen });
<TopupDialog v-model="topupOpen" @success="onTopupSuccess" />
<v-snackbar v-model="topupSnackbar" color="success" :timeout="4000">
Баланс пополнен.
</v-snackbar>
<v-snackbar v-model="topupSnackbar" color="success" :timeout="4000"> Баланс пополнен. </v-snackbar>
</v-container>
</template>
+7 -1
View File
@@ -22,7 +22,13 @@ const RUNWAY_MAX = 7;
// Mock-fallback — UI работоспособен без backend (dev / 500 / нет auth).
const MOCK_KPIS: Kpi[] = [
{ label: 'Получено лидов', value: '247', delta: { dir: 'up', text: '12.3%' }, sub: 'vs предыдущий период' },
{ label: 'Конверсия в оплату', value: '18.4', unit: '%', delta: { dir: 'up', text: '2.1pp' }, sub: 'vs предыдущий период' },
{
label: 'Конверсия в оплату',
value: '18.4',
unit: '%',
delta: { dir: 'up', text: '2.1pp' },
sub: 'vs предыдущий период',
},
{ label: 'Активные проекты', value: '8', unit: '/ 10', delta: { dir: 'neutral', text: '' }, sub: 'лимит тарифа' },
];
const MOCK_BALANCE: Balance = { amount: '14 250', runwayDays: 4, runwayMax: RUNWAY_MAX, runwayLeads: 285 };
+33 -7
View File
@@ -255,17 +255,43 @@ onMounted(async () => {
openDealFromQuery();
});
watch(() => route.query.openId, () => openDealFromQuery());
watch(
() => route.query.openId,
() => openDealFromQuery(),
);
// Polling — авто-refresh текущей страницы (pause при скрытой вкладке).
usePolling(loadDeals);
defineExpose({
searchPhone, filterStatus, filterProject, filterCity, receivedFrom, receivedTo,
perPage, page, pageCount, dealsState, total, loading, fetchError, availableProjects,
selected, selectedDeal, panelOpen, statusMenuOpen,
loadDeals, clearFilters, applyBulkStatus, exportByRange, openPanel, closePanel,
exportToastOpen, exportToastText, statusToastOpen, statusToastText,
searchPhone,
filterStatus,
filterProject,
filterCity,
receivedFrom,
receivedTo,
perPage,
page,
pageCount,
dealsState,
total,
loading,
fetchError,
availableProjects,
selected,
selectedDeal,
panelOpen,
statusMenuOpen,
loadDeals,
clearFilters,
applyBulkStatus,
exportByRange,
openPanel,
closePanel,
exportToastOpen,
exportToastText,
statusToastOpen,
statusToastText,
});
</script>
@@ -389,7 +415,7 @@ defineExpose({
:deals="dealsState"
:selected-ids="selected"
:status-by-slug="statusBySlug"
:active-deal-id="panelOpen ? selectedDeal?.id ?? null : null"
:active-deal-id="panelOpen ? (selectedDeal?.id ?? null) : null"
@update:selected-ids="selected = $event"
@row-click="openPanel"
/>
+3 -14
View File
@@ -32,9 +32,7 @@ let pollTimer: ReturnType<typeof setInterval> | null = null;
const canUpload = computed(() => file.value !== null && !uploading.value);
const isProcessing = computed(
() =>
activeImport.value?.status === 'pending' ||
activeImport.value?.status === 'processing',
() => activeImport.value?.status === 'pending' || activeImport.value?.status === 'processing',
);
async function refreshHistory(): Promise<void> {
@@ -190,12 +188,7 @@ onUnmounted(stopPolling);
</tr>
</tbody>
</v-table>
<v-alert
v-if="activeImport.status === 'failed'"
type="error"
variant="tonal"
class="mt-3"
>
<v-alert v-if="activeImport.status === 'failed'" type="error" variant="tonal" class="mt-3">
{{ activeImport.error_message }}
</v-alert>
</v-card>
@@ -225,11 +218,7 @@ onUnmounted(stopPolling);
<p v-else class="text-body-2 text-medium-emphasis ma-0">Импортов пока нет.</p>
</v-card>
<UnknownStatusesDialog
v-model="wizardOpen"
:statuses="unknownStatuses"
@resolved="onWizardResolved"
/>
<UnknownStatusesDialog v-model="wizardOpen" :statuses="unknownStatuses" @resolved="onWizardResolved" />
</v-container>
</template>
+5 -11
View File
@@ -15,9 +15,9 @@
>
<div class="d-flex justify-space-between align-start gap-2">
<span>
Важно: изменения по проектам (добавление, удаление, лимиты, рабочие дни, регионы)
вносите <strong>до 18:00 МСК</strong>. Изменения после 18:00 применяются при следующей
синхронизации на следующий день.
Важно: изменения по проектам (добавление, удаление, лимиты, рабочие дни, регионы) вносите
<strong>до 18:00 МСК</strong>. Изменения после 18:00 применяются при следующей синхронизации на
следующий день.
</span>
<v-btn
data-testid="cutoff-banner-close"
@@ -168,11 +168,7 @@
<BulkActionsBar v-if="store.selectedIds.size >= 2" />
<ProjectDetailsDrawer
:project="singleSelectedProject"
@close="onDrawerClose"
@saved="onDrawerSaved"
/>
<ProjectDetailsDrawer :project="singleSelectedProject" @close="onDrawerClose" @saved="onDrawerSaved" />
<NewProjectDialog v-model="createOpen" mode="create" @saved="onProjectSaved" />
<EditProjectDialog v-model="editOpen" :project="editing" @saved="onProjectSaved" />
@@ -285,9 +281,7 @@ const sortItems = [
];
// #6: общее число страниц для пагинатора.
const pageCount = computed(() =>
Math.max(1, Math.ceil(store.total / Math.max(1, store.filters.per_page))),
);
const pageCount = computed(() => Math.max(1, Math.ceil(store.total / Math.max(1, store.filters.per_page))));
// При смене per_page/sort/region/delivery_day — сброс на 1-ю страницу + fetch.
function onResetPageAndFetch(): void {
@@ -112,7 +112,10 @@ async function confirmAction() {
actionError.value = 'Укажите основание (минимум 10 символов).';
return;
}
if (actionDialog.value === 'refund' && (actionAmount.value === null || !Number.isFinite(actionAmount.value) || actionAmount.value <= 0)) {
if (
actionDialog.value === 'refund' &&
(actionAmount.value === null || !Number.isFinite(actionAmount.value) || actionAmount.value <= 0)
) {
actionError.value = 'Укажите сумму возврата больше нуля.';
return;
}
@@ -392,12 +395,7 @@ function tariffLabel(t: string): string {
</v-card-text>
<v-card-actions class="justify-end">
<v-btn variant="text" @click="actionDialog = null">Отмена</v-btn>
<v-btn
:loading="actionLoading"
color="primary"
variant="flat"
@click="confirmAction"
>
<v-btn :loading="actionLoading" color="primary" variant="flat" @click="confirmAction">
Подтвердить
</v-btn>
</v-card-actions>
@@ -445,12 +443,7 @@ function tariffLabel(t: string): string {
</v-card-text>
<v-card-actions class="justify-end">
<v-btn variant="text" @click="actionDialog = null">Отмена</v-btn>
<v-btn
:loading="actionLoading"
color="primary"
variant="flat"
@click="confirmAction"
>
<v-btn :loading="actionLoading" color="primary" variant="flat" @click="confirmAction">
Выполнить возврат
</v-btn>
</v-card-actions>
@@ -498,12 +491,7 @@ function tariffLabel(t: string): string {
</v-card-text>
<v-card-actions class="justify-end">
<v-btn variant="text" @click="actionDialog = null">Отмена</v-btn>
<v-btn
:loading="actionLoading"
color="primary"
variant="flat"
@click="confirmAction"
>
<v-btn :loading="actionLoading" color="primary" variant="flat" @click="confirmAction">
Сменить тариф
</v-btn>
</v-card-actions>
@@ -128,9 +128,7 @@ defineExpose({
<!-- Fetch error -->
<v-container v-else-if="fetchError" fluid class="pa-6" data-testid="incident-fetch-error">
<v-alert type="warning" variant="tonal" class="mb-4">
Не удалось загрузить инцидент: {{ fetchError }}
</v-alert>
<v-alert type="warning" variant="tonal" class="mb-4"> Не удалось загрузить инцидент: {{ fetchError }} </v-alert>
<div class="d-flex ga-2">
<v-btn variant="outlined" prepend-icon="mdi-refresh" @click="loadIncident">Повторить</v-btn>
<v-btn variant="text" prepend-icon="mdi-arrow-left" @click="goBack">К списку</v-btn>
@@ -138,20 +138,20 @@ async function confirmErase(): Promise<void> {
// Helpers
// ---------------------------------------------------------------------------
const statusLabels: Record<string, { label: string; color: string }> = {
received: { label: 'Получено', color: 'info' },
in_progress: { label: 'В работе', color: 'warning' },
completed: { label: 'Выполнено', color: 'success' },
rejected: { label: 'Отклонено', color: 'error' },
received: { label: 'Получено', color: 'info' },
in_progress: { label: 'В работе', color: 'warning' },
completed: { label: 'Выполнено', color: 'success' },
rejected: { label: 'Отклонено', color: 'error' },
};
function statusInfo(s: string) {
return statusLabels[s] ?? { label: s, color: 'default' };
}
const typeLabels: Record<string, string> = {
access: 'Доступ',
rectification: 'Исправление',
deletion: 'Удаление',
objection: 'Возражение',
access: 'Доступ',
rectification: 'Исправление',
deletion: 'Удаление',
objection: 'Возражение',
};
function typeLabel(t: string): string {
return typeLabels[t] ?? t;
@@ -160,19 +160,22 @@ function typeLabel(t: string): string {
function formatDate(iso: string | null): string {
if (!iso) return '—';
return new Date(iso).toLocaleString('ru-RU', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
const headers = [
{ title: 'ID', key: 'id', width: '60px' },
{ title: 'Получено', key: 'received_at', width: '140px' },
{ title: 'Email / тел.', key: 'contact', sortable: false },
{ title: 'Тип', key: 'request_type', width: '110px' },
{ title: 'Статус', key: 'status', width: '120px' },
{ title: 'Дедлайн', key: 'deadline_at', width: '140px' },
{ title: 'Действия', key: 'actions', sortable: false, width: '140px', align: 'end' as const },
{ title: 'ID', key: 'id', width: '60px' },
{ title: 'Получено', key: 'received_at', width: '140px' },
{ title: 'Email / тел.', key: 'contact', sortable: false },
{ title: 'Тип', key: 'request_type', width: '110px' },
{ title: 'Статус', key: 'status', width: '120px' },
{ title: 'Дедлайн', key: 'deadline_at', width: '140px' },
{ title: 'Действия', key: 'actions', sortable: false, width: '140px', align: 'end' as const },
];
const filteredRows = computed(() => rows.value);
@@ -187,8 +190,7 @@ defineExpose({ rows, loading, fetchError, loadRows });
<div>
<h1 class="text-h4 page-title">Обращения субъектов ПДн</h1>
<p class="text-body-2 text-medium-emphasis ma-0">
Обращения на доступ, исправление, удаление и возражение (152-ФЗ).
Срок ответа 30 дней.
Обращения на доступ, исправление, удаление и возражение (152-ФЗ). Срок ответа 30 дней.
</p>
</div>
<div class="d-flex ga-2">
@@ -201,12 +203,7 @@ defineExpose({ rows, loading, fetchError, loadRows });
>
Обновить
</v-btn>
<v-btn
color="primary"
prepend-icon="mdi-plus"
data-testid="create-btn"
@click="createDialog = true"
>
<v-btn color="primary" prepend-icon="mdi-plus" data-testid="create-btn" @click="createDialog = true">
Новый запрос
</v-btn>
</div>
@@ -233,10 +230,10 @@ defineExpose({ rows, loading, fetchError, loadRows });
label="Статус"
:items="[
{ title: 'Все статусы', value: '' },
{ title: 'Получено', value: 'received' },
{ title: 'В работе', value: 'in_progress' },
{ title: 'Выполнено', value: 'completed' },
{ title: 'Отклонено', value: 'rejected' },
{ title: 'Получено', value: 'received' },
{ title: 'В работе', value: 'in_progress' },
{ title: 'Выполнено', value: 'completed' },
{ title: 'Отклонено', value: 'rejected' },
]"
density="compact"
variant="outlined"
@@ -249,11 +246,11 @@ defineExpose({ rows, loading, fetchError, loadRows });
v-model="filterType"
label="Тип обращения"
:items="[
{ title: 'Все типы', value: '' },
{ title: 'Доступ', value: 'access' },
{ title: 'Все типы', value: '' },
{ title: 'Доступ', value: 'access' },
{ title: 'Исправление', value: 'rectification' },
{ title: 'Удаление', value: 'deletion' },
{ title: 'Возражение', value: 'objection' },
{ title: 'Удаление', value: 'deletion' },
{ title: 'Возражение', value: 'objection' },
]"
density="compact"
variant="outlined"
@@ -305,11 +302,7 @@ defineExpose({ rows, loading, fetchError, loadRows });
</template>
<template v-slot:[`item.status`]="{ item }">
<v-chip
:color="statusInfo(item.status).color"
size="x-small"
variant="tonal"
>
<v-chip :color="statusInfo(item.status).color" size="x-small" variant="tonal">
{{ statusInfo(item.status).label }}
</v-chip>
</template>
@@ -317,7 +310,9 @@ defineExpose({ rows, loading, fetchError, loadRows });
<template v-slot:[`item.deadline_at`]="{ item }">
<span
class="text-caption"
:class="item.status !== 'completed' && new Date(item.deadline_at) < new Date() ? 'text-error' : ''"
:class="
item.status !== 'completed' && new Date(item.deadline_at) < new Date() ? 'text-error' : ''
"
>
{{ formatDate(item.deadline_at) }}
</span>
@@ -335,12 +330,7 @@ defineExpose({ rows, loading, fetchError, loadRows });
>
Анонимизировать
</v-btn>
<v-chip
v-else-if="item.status === 'completed'"
color="success"
size="x-small"
variant="text"
>
<v-chip v-else-if="item.status === 'completed'" color="success" size="x-small" variant="text">
Выполнено
</v-chip>
</template>
@@ -352,13 +342,7 @@ defineExpose({ rows, loading, fetchError, loadRows });
<v-card>
<v-card-title class="text-h6 pa-4 pb-2">Новое обращение субъекта ПДн</v-card-title>
<v-card-text class="pa-4 pt-0">
<v-alert
v-if="createError"
type="error"
variant="tonal"
density="compact"
class="mb-3"
>
<v-alert v-if="createError" type="error" variant="tonal" density="compact" class="mb-3">
{{ createError }}
</v-alert>
@@ -366,10 +350,10 @@ defineExpose({ rows, loading, fetchError, loadRows });
v-model="createForm.request_type"
label="Тип обращения *"
:items="[
{ title: 'Доступ к данным', value: 'access' },
{ title: 'Доступ к данным', value: 'access' },
{ title: 'Исправление данных', value: 'rectification' },
{ title: 'Удаление данных', value: 'deletion' },
{ title: 'Возражение', value: 'objection' },
{ title: 'Удаление данных', value: 'deletion' },
{ title: 'Возражение', value: 'objection' },
]"
density="compact"
variant="outlined"
@@ -418,7 +402,14 @@ defineExpose({ rows, loading, fetchError, loadRows });
/>
</v-card-text>
<v-card-actions class="pa-4 pt-0 justify-end">
<v-btn variant="text" @click="createDialog = false; resetCreateForm()">Отмена</v-btn>
<v-btn
variant="text"
@click="
createDialog = false;
resetCreateForm();
"
>Отмена</v-btn
>
<v-btn
color="primary"
:loading="createLoading"
@@ -434,39 +425,38 @@ defineExpose({ rows, loading, fetchError, loadRows });
<!-- Dialog: erase confirm -->
<v-dialog v-model="eraseDialog" max-width="480" data-testid="erase-dialog">
<v-card>
<v-card-title class="text-h6 pa-4 pb-2 text-error">
Анонимизировать данные субъекта
</v-card-title>
<v-card-title class="text-h6 pa-4 pb-2 text-error"> Анонимизировать данные субъекта </v-card-title>
<v-card-text class="pa-4 pt-0">
<template v-if="!eraseResult">
<v-alert type="warning" variant="tonal" density="compact" class="mb-3">
Операция необратима. Данные будут заменены плейсхолдерами.
</v-alert>
<p class="text-body-2 mb-1">
<strong>Email:</strong> {{ eraseTarget?.subject_email ?? '—' }}
</p>
<p class="text-body-2 mb-1"><strong>Email:</strong> {{ eraseTarget?.subject_email ?? '—' }}</p>
<p class="text-body-2 mb-1">
<strong>Телефон:</strong> {{ eraseTarget?.subject_phone ?? '—' }}
</p>
<p class="text-body-2">
<strong>Тенант:</strong> {{ eraseTarget?.tenant_id ?? 'все' }}
</p>
<p class="text-body-2"><strong>Тенант:</strong> {{ eraseTarget?.tenant_id ?? 'все' }}</p>
</template>
<template v-else>
<v-alert type="success" variant="tonal" density="compact" class="mb-3">
Анонимизация выполнена.
</v-alert>
<p class="text-body-2 mb-1">Пользователей: <strong>{{ eraseResult.users }}</strong></p>
<p class="text-body-2 mb-1">Лидов поставщика: <strong>{{ eraseResult.leads }}</strong></p>
<p class="text-body-2 mb-1">Сделок: <strong>{{ eraseResult.deals }}</strong></p>
<p class="text-body-2">Webhook-логов: <strong>{{ eraseResult.webhook_log }}</strong></p>
<p class="text-body-2 mb-1">
Пользователей: <strong>{{ eraseResult.users }}</strong>
</p>
<p class="text-body-2 mb-1">
Лидов поставщика: <strong>{{ eraseResult.leads }}</strong>
</p>
<p class="text-body-2 mb-1">
Сделок: <strong>{{ eraseResult.deals }}</strong>
</p>
<p class="text-body-2">
Webhook-логов: <strong>{{ eraseResult.webhook_log }}</strong>
</p>
</template>
</v-card-text>
<v-card-actions class="pa-4 pt-0 justify-end">
<v-btn
variant="text"
@click="eraseDialog = false"
>
<v-btn variant="text" @click="eraseDialog = false">
{{ eraseResult ? 'Закрыть' : 'Отмена' }}
</v-btn>
<v-btn
@@ -116,8 +116,7 @@
<v-card>
<v-card-title>Удалить запланированный набор?</v-card-title>
<v-card-text>
Запланированная сетка с <strong>{{ deleteTarget }}</strong> будет удалена.
Действие необратимо.
Запланированная сетка с <strong>{{ deleteTarget }}</strong> будет удалена. Действие необратимо.
</v-card-text>
<v-card-actions>
<v-spacer />
@@ -141,7 +140,13 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { getPricingTiers, createPricingTiers, deleteScheduledPricingTier, type AdminPricingTier, type PricingTierEditorRow } from '../../api/admin';
import {
getPricingTiers,
createPricingTiers,
deleteScheduledPricingTier,
type AdminPricingTier,
type PricingTierEditorRow,
} from '../../api/admin';
import { extractErrorMessage } from '../../api/client';
/**
@@ -160,25 +160,17 @@ onMounted(() => {
density="comfortable"
:disabled="exportModeSaving"
>
<v-btn
data-testid="export-mode-online"
value="online"
@click="setExportMode('online')"
>
<v-btn data-testid="export-mode-online" value="online" @click="setExportMode('online')">
Онлайн
</v-btn>
<v-btn
data-testid="export-mode-batch"
value="batch"
@click="setExportMode('batch')"
>
<v-btn data-testid="export-mode-batch" value="batch" @click="setExportMode('batch')">
Пакетный
</v-btn>
</v-btn-toggle>
</div>
<p class="text-caption text-medium-emphasis mt-3 mb-0">
Онлайн изменения проекта переносятся к поставщику сразу.
Пакетный ночной синк в 18:00 (SyncSupplierProjectsJob).
Онлайн изменения проекта переносятся к поставщику сразу. Пакетный ночной синк в 18:00
(SyncSupplierProjectsJob).
</p>
</v-card-text>
</v-card>
@@ -209,12 +201,7 @@ onMounted(() => {
</div>
</template>
<div v-else-if="loading" class="mb-4 text-medium-emphasis">Загрузка</div>
<v-btn
data-test="reconcile-now"
color="primary"
:loading="reconciling"
@click="reconcileNow"
>
<v-btn data-test="reconcile-now" color="primary" :loading="reconciling" @click="reconcileNow">
Сверить сейчас
</v-btn>
</v-card-text>
@@ -129,7 +129,11 @@ async function save(s: AdminSupplier): Promise<void> {
saving[s.id] = true;
delete errorMessages[s.id]; // очистить предыдущую ошибку перед retry
try {
await updateAdminSupplier(s.id, { cost_rub: s.cost_rub, quality_score: s.quality_score, is_active: s.is_active });
await updateAdminSupplier(s.id, {
cost_rub: s.cost_rub,
quality_score: s.quality_score,
is_active: s.is_active,
});
successToastText.value = `Сохранено: ${s.name} (${s.code}).`;
successToastOpen.value = true;
} catch (err) {
@@ -2,8 +2,8 @@
<div class="admin-supplier-projects-view pa-6">
<h1 class="text-h5 mb-4">Проекты у поставщика</h1>
<p class="text-body-2 text-medium-emphasis mb-4">
Все проекты, заведённые у поставщика crm.bp-gr.ru. Удаление снимает проект
на портале и локальные привязки тенантов (каскадом).
Все проекты, заведённые у поставщика crm.bp-gr.ru. Удаление снимает проект на портале и локальные привязки
тенантов (каскадом).
</p>
<v-alert
@@ -32,19 +32,11 @@
Удалить выбранные ({{ selected.length }})
</v-btn>
<v-spacer />
<v-btn variant="text" prepend-icon="mdi-refresh" :loading="loading" @click="load">
Обновить
</v-btn>
<v-btn variant="text" prepend-icon="mdi-refresh" :loading="loading" @click="load"> Обновить </v-btn>
</div>
<v-card elevation="1">
<v-data-table
:headers="headers"
:items="projects"
:loading="loading"
density="comfortable"
item-value="id"
>
<v-data-table :headers="headers" :items="projects" :loading="loading" density="comfortable" item-value="id">
<template #[`item.select`]="{ item }">
<v-checkbox
:model-value="selected.includes(item.id)"
@@ -68,9 +60,8 @@
<v-card>
<v-card-title>Удалить выбранные проекты?</v-card-title>
<v-card-text>
Будет удалено проектов: <strong>{{ selected.length }}</strong>.
Действие снимает проекты у поставщика и локальные привязки.
Отменить нельзя.
Будет удалено проектов: <strong>{{ selected.length }}</strong
>. Действие снимает проекты у поставщика и локальные привязки. Отменить нельзя.
</v-card-text>
<v-card-actions>
<v-spacer />
+2 -7
View File
@@ -115,15 +115,10 @@ async function handleSubmit() {
<span class="text-caption text-medium-emphasis">или</span>
</v-divider>
<v-tooltip
text="Вход через Yandex 360 станет доступен после регистрации юр. лица (Б-1)."
location="top"
>
<v-tooltip text="Вход через Yandex 360 станет доступен после регистрации юр. лица (Б-1)." location="top">
<template #activator="{ props }">
<div v-bind="props" class="yandex-sso-wrap">
<v-btn block size="large" variant="outlined" disabled>
Войти через Yandex 360
</v-btn>
<v-btn block size="large" variant="outlined" disabled> Войти через Yandex 360 </v-btn>
</div>
</template>
</v-tooltip>
@@ -36,7 +36,10 @@
<template #[`item.price_rub`]="{ item }">
<span v-if="item.price_per_lead_kopecks === 0" class="text-medium-emphasis">
0
<v-tooltip activator="parent" text="До перехода на новую модель эти лиды списывались из бесплатного остатка." />
<v-tooltip
activator="parent"
text="До перехода на новую модель эти лиды списывались из бесплатного остатка."
/>
<span class="text-caption ml-1">(из бесплатного)</span>
</span>
<span v-else>{{ (item.price_per_lead_kopecks / 100).toFixed(2) }} </span>
@@ -129,8 +129,8 @@
class="mt-2"
data-testid="vsya-rf-warning"
>
Вы выбрали всю Россию — проект будет получать лиды по всем регионам
(всем субъектам РФ). Подтвердите, что это намеренно.
Вы выбрали всю Россию — проект будет получать лиды по всем регионам (всем субъектам РФ).
Подтвердите, что это намеренно.
<div class="mt-2">
<v-btn
size="small"
@@ -141,9 +141,7 @@
>
Подтверждаю «Вся РФ»
</v-btn>
<v-btn size="small" variant="text" class="ml-2" @click="cancelVsyaRf">
Отмена
</v-btn>
<v-btn size="small" variant="text" class="ml-2" @click="cancelVsyaRf"> Отмена </v-btn>
</div>
</v-alert>
+1 -1
View File
@@ -1,9 +1,9 @@
<?php
use App\Jobs\SnapshotProjectRoutingJob;
use App\Jobs\Supplier\CleanupInactiveSupplierProjectsJob;
use App\Jobs\Supplier\CsvReconcileJob;
use App\Jobs\Supplier\RefreshSupplierSessionJob;
use App\Jobs\SnapshotProjectRoutingJob;
use App\Jobs\Supplier\SyncSupplierProjectsJob;
use App\Services\SchedulerHeartbeatTracker;
use Illuminate\Foundation\Inspiring;
@@ -20,7 +20,6 @@ uses(DatabaseTransactions::class);
* Asserts that within an INSERT transaction the advisory lock key derived
* from the partition OID is held (proves the lock is actually acquired).
*/
it(
'audit_chain_hash trigger preserves sequential chain under concurrent INSERTs',
function (): void {
@@ -43,8 +42,8 @@ it(
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
DB::table('activity_log')->insert([
'tenant_id' => $tenant->id,
'event' => 'deal.created',
'context' => json_encode(['worker' => $i]),
'event' => 'deal.created',
'context' => json_encode(['worker' => $i]),
'created_at' => now(),
]);
exit(0);
@@ -97,9 +96,9 @@ it('audit_chain_hash holds pg_advisory_xact_lock on the partition OID during INS
$lockHeld = false;
DB::transaction(function () use ($tenant, $lockKey, &$lockHeld): void {
DB::table('activity_log')->insert([
'tenant_id' => $tenant->id,
'event' => 'deal.created',
'context' => json_encode(['test' => 'advisory_lock_check']),
'tenant_id' => $tenant->id,
'event' => 'deal.created',
'context' => json_encode(['test' => 'advisory_lock_check']),
'created_at' => now(),
]);
@@ -18,7 +18,7 @@ it('creates snapshot for given date from current live state', function () {
$this->artisan('snapshot:backfill', ['--date' => '2026-05-27'])
->assertSuccessful();
expect(\DB::table('project_routing_snapshots')->where('snapshot_date', '2026-05-27')->count())->toBe(1);
expect(DB::table('project_routing_snapshots')->where('snapshot_date', '2026-05-27')->count())->toBe(1);
Carbon::setTestNow();
});
@@ -34,6 +34,6 @@ it('is idempotent — does not duplicate on re-run', function () {
$this->artisan('snapshot:backfill', ['--date' => '2026-05-27'])->assertSuccessful();
$this->artisan('snapshot:backfill', ['--date' => '2026-05-27'])->assertSuccessful();
expect(\DB::table('project_routing_snapshots')->count())->toBe(1);
expect(DB::table('project_routing_snapshots')->count())->toBe(1);
Carbon::setTestNow();
});
@@ -4,6 +4,7 @@ declare(strict_types=1);
use App\Jobs\RouteSupplierLeadJob;
use App\Models\Deal;
use App\Models\LeadCharge;
use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
@@ -18,6 +19,7 @@ use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Mockery as M;
use Random\Engine\Mt19937;
use Random\Randomizer;
@@ -578,7 +580,7 @@ it('merges webhook into csv-recovered deal even when received_at differs (Phase
]);
// LeadCharge на CSV-recovered deal — это что триггерит FK при UPDATE received_at.
\App\Models\LeadCharge::factory()->create([
LeadCharge::factory()->create([
'tenant_id' => $tenant->id,
'deal_id' => $csvDeal->id,
'deal_received_at' => $csvDeal->received_at,
@@ -633,7 +635,7 @@ it('merges webhook into csv-recovered deal even when received_at differs (Phase
});
it('fills deal city with the resolved region name (UI «Город» column)', function (): void {
\Illuminate\Support\Facades\Http::fake(['cleaner.dadata.ru/*' => \Illuminate\Support\Facades\Http::response([[
Http::fake(['cleaner.dadata.ru/*' => Http::response([[
'qc' => 0, 'region' => 'Москва', 'provider' => 'МТС',
]], 200)]);
config([
@@ -41,7 +41,7 @@ it('creates snapshot for tomorrow with active projects only', function () {
'signal_identifier' => '79169999999',
]);
(new SnapshotProjectRoutingJob)->handle();
$rows = \DB::table('project_routing_snapshots')
$rows = DB::table('project_routing_snapshots')
->where('snapshot_date', '2026-05-28')->get();
expect($rows)->toHaveCount(1);
expect($rows->first()->project_id)->toBe($active->id);
@@ -53,7 +53,7 @@ it('excludes frozen tenants', function () {
'is_active' => true, 'delivery_days_mask' => 127, 'daily_limit_target' => 10, 'signal_type' => 'call', 'signal_identifier' => '79161234567',
]);
(new SnapshotProjectRoutingJob)->handle();
expect(\DB::table('project_routing_snapshots')->count())->toBe(0);
expect(DB::table('project_routing_snapshots')->count())->toBe(0);
});
it('excludes preflight_blocked projects', function () {
@@ -63,7 +63,7 @@ it('excludes preflight_blocked projects', function () {
'preflight_blocked_at' => now(),
]);
(new SnapshotProjectRoutingJob)->handle();
expect(\DB::table('project_routing_snapshots')->count())->toBe(0);
expect(DB::table('project_routing_snapshots')->count())->toBe(0);
});
it('excludes projects whose days_mask does not match tomorrow', function () {
@@ -77,7 +77,7 @@ it('excludes projects whose days_mask does not match tomorrow', function () {
'signal_identifier' => '79161234567',
]);
(new SnapshotProjectRoutingJob)->handle();
expect(\DB::table('project_routing_snapshots')->count())->toBe(0);
expect(DB::table('project_routing_snapshots')->count())->toBe(0);
});
it('uses effective_daily_limit_today as daily_limit when set (R-11/OPEN-5 variant A)', function () {
@@ -90,7 +90,7 @@ it('uses effective_daily_limit_today as daily_limit when set (R-11/OPEN-5 varian
'signal_identifier' => '79161234567',
]);
(new SnapshotProjectRoutingJob)->handle();
$row = \DB::table('project_routing_snapshots')->first();
$row = DB::table('project_routing_snapshots')->first();
expect($row->daily_limit)->toBe(3);
});
@@ -101,5 +101,5 @@ it('is idempotent — second run does not duplicate', function () {
]);
(new SnapshotProjectRoutingJob)->handle();
(new SnapshotProjectRoutingJob)->handle();
expect(\DB::table('project_routing_snapshots')->count())->toBe(1);
expect(DB::table('project_routing_snapshots')->count())->toBe(1);
});
@@ -28,36 +28,36 @@ afterEach(function (): void {
// ---------------------------------------------------------------------------
it('does not match B-platform project for frozen tenant (frozen_by_balance_at IS NOT NULL)', function () {
$tenant = Tenant::factory()->create([
'balance_rub' => '500.00',
'balance_rub' => '500.00',
'frozen_by_balance_at' => now(), // frozen — R-03
]);
$project = Project::factory()->for($tenant)->create([
'is_active' => true,
'delivery_days_mask' => 127,
'daily_limit_target' => 10,
'delivered_today' => 0,
'is_active' => true,
'delivery_days_mask' => 127,
'daily_limit_target' => 10,
'delivered_today' => 0,
]);
$sp = SupplierProject::factory()->create(['platform' => 'B1']);
DB::table('project_supplier_links')->insert([
'project_id' => $project->id,
'project_id' => $project->id,
'supplier_project_id' => $sp->id,
'platform' => $sp->platform,
'subject_code' => null,
'platform' => $sp->platform,
'subject_code' => null,
]);
DB::table('project_routing_snapshots')->insert([
'snapshot_date' => '2026-05-28',
'project_id' => $project->id,
'tenant_id' => $tenant->id,
'daily_limit' => 10,
'delivery_days_mask' => 127,
'regions' => '{}',
'signal_type' => 'call',
'signal_identifier' => null,
'sms_senders' => null,
'sms_keyword' => null,
'expected_volume' => 10,
'delivered_count' => 0,
'created_at' => now(),
'snapshot_date' => '2026-05-28',
'project_id' => $project->id,
'tenant_id' => $tenant->id,
'daily_limit' => 10,
'delivery_days_mask' => 127,
'regions' => '{}',
'signal_type' => 'call',
'signal_identifier' => null,
'sms_senders' => null,
'sms_keyword' => null,
'expected_volume' => 10,
'delivered_count' => 0,
'created_at' => now(),
]);
$matched = app(LeadRouter::class)->matchEligibleProjects($sp);
@@ -70,38 +70,38 @@ it('does not match B-platform project for frozen tenant (frozen_by_balance_at IS
// ---------------------------------------------------------------------------
it('does not match DIRECT-platform project for frozen tenant (frozen_by_balance_at IS NOT NULL)', function () {
$tenant = Tenant::factory()->create([
'balance_rub' => '500.00',
'balance_rub' => '500.00',
'frozen_by_balance_at' => now(), // frozen — R-03
]);
$project = Project::factory()->for($tenant)->create([
'is_active' => true,
'delivery_days_mask' => 127,
'daily_limit_target' => 10,
'delivered_today' => 0,
'is_active' => true,
'delivery_days_mask' => 127,
'daily_limit_target' => 10,
'delivered_today' => 0,
]);
// DIRECT supplier_project matches via signal_type + unique_key
$sp = SupplierProject::factory()->create([
'platform' => 'DIRECT',
'platform' => 'DIRECT',
'signal_type' => 'call',
'unique_key' => 'direct-test-frozen-001',
'unique_key' => 'direct-test-frozen-001',
]);
// Snapshot must carry signal_type + signal_identifier matching sp->unique_key
DB::table('project_routing_snapshots')->insert([
'snapshot_date' => '2026-05-28',
'project_id' => $project->id,
'tenant_id' => $tenant->id,
'daily_limit' => 10,
'delivery_days_mask' => 127,
'regions' => '{}',
'signal_type' => 'call',
'signal_identifier' => 'direct-test-frozen-001', // matches sp->unique_key
'sms_senders' => null,
'sms_keyword' => null,
'expected_volume' => 10,
'delivered_count' => 0,
'created_at' => now(),
'snapshot_date' => '2026-05-28',
'project_id' => $project->id,
'tenant_id' => $tenant->id,
'daily_limit' => 10,
'delivery_days_mask' => 127,
'regions' => '{}',
'signal_type' => 'call',
'signal_identifier' => 'direct-test-frozen-001', // matches sp->unique_key
'sms_senders' => null,
'sms_keyword' => null,
'expected_volume' => 10,
'delivered_count' => 0,
'created_at' => now(),
]);
$matched = app(LeadRouter::class)->matchEligibleProjects($sp);
@@ -114,36 +114,36 @@ it('does not match DIRECT-platform project for frozen tenant (frozen_by_balance_
// ---------------------------------------------------------------------------
it('matches B-platform project for non-frozen tenant (frozen_by_balance_at IS NULL)', function () {
$tenant = Tenant::factory()->create([
'balance_rub' => '500.00',
'balance_rub' => '500.00',
'frozen_by_balance_at' => null, // NOT frozen — should match
]);
$project = Project::factory()->for($tenant)->create([
'is_active' => true,
'delivery_days_mask' => 127,
'daily_limit_target' => 10,
'delivered_today' => 0,
'is_active' => true,
'delivery_days_mask' => 127,
'daily_limit_target' => 10,
'delivered_today' => 0,
]);
$sp = SupplierProject::factory()->create(['platform' => 'B1']);
DB::table('project_supplier_links')->insert([
'project_id' => $project->id,
'project_id' => $project->id,
'supplier_project_id' => $sp->id,
'platform' => $sp->platform,
'subject_code' => null,
'platform' => $sp->platform,
'subject_code' => null,
]);
DB::table('project_routing_snapshots')->insert([
'snapshot_date' => '2026-05-28',
'project_id' => $project->id,
'tenant_id' => $tenant->id,
'daily_limit' => 10,
'delivery_days_mask' => 127,
'regions' => '{}',
'signal_type' => 'call',
'signal_identifier' => null,
'sms_senders' => null,
'sms_keyword' => null,
'expected_volume' => 10,
'delivered_count' => 0,
'created_at' => now(),
'snapshot_date' => '2026-05-28',
'project_id' => $project->id,
'tenant_id' => $tenant->id,
'daily_limit' => 10,
'delivery_days_mask' => 127,
'regions' => '{}',
'signal_type' => 'call',
'signal_identifier' => null,
'sms_senders' => null,
'sms_keyword' => null,
'expected_volume' => 10,
'delivered_count' => 0,
'created_at' => now(),
]);
$matched = app(LeadRouter::class)->matchEligibleProjects($sp);
@@ -19,12 +19,12 @@ it('uses snapshot before 21:00 MSK, snapshot_date = today', function () {
'delivered_today' => 0,
]);
$sp = SupplierProject::factory()->create();
\DB::table('project_supplier_links')->insert([
DB::table('project_supplier_links')->insert([
'project_id' => $project->id, 'supplier_project_id' => $sp->id,
'platform' => $sp->platform, 'subject_code' => null,
]);
// SNAPSHOT за сегодня имеет проект → роутер должен вернуть, несмотря на is_active=false
\DB::table('project_routing_snapshots')->insert([
DB::table('project_routing_snapshots')->insert([
'snapshot_date' => '2026-05-28', 'project_id' => $project->id, 'tenant_id' => $tenant->id,
'daily_limit' => 10, 'delivery_days_mask' => 127, 'regions' => '{}',
'signal_type' => 'call', 'expected_volume' => 10, 'delivered_count' => 0,
@@ -46,13 +46,13 @@ it('uses snapshot after 21:00 MSK, snapshot_date = tomorrow', function () {
'daily_limit_target' => 100, 'delivered_today' => 0,
]);
$sp = SupplierProject::factory()->create();
\DB::table('project_supplier_links')->insert([
DB::table('project_supplier_links')->insert([
'project_id' => $project->id, 'supplier_project_id' => $sp->id,
'platform' => $sp->platform, 'subject_code' => null,
]);
// Snapshot за СЕГОДНЯ (2026-05-28) НЕТ.
// Snapshot за ЗАВТРА (2026-05-29) есть.
\DB::table('project_routing_snapshots')->insert([
DB::table('project_routing_snapshots')->insert([
'snapshot_date' => '2026-05-29', 'project_id' => $project->id, 'tenant_id' => $tenant->id,
'daily_limit' => 10, 'delivery_days_mask' => 127, 'regions' => '{}',
'signal_type' => 'call', 'expected_volume' => 10, 'delivered_count' => 0,
@@ -73,7 +73,7 @@ it('returns 0 if no snapshot exists for active date', function () {
'is_active' => true, 'delivery_days_mask' => 127, 'daily_limit_target' => 10,
]);
$sp = SupplierProject::factory()->create();
\DB::table('project_supplier_links')->insert([
DB::table('project_supplier_links')->insert([
'project_id' => $project->id, 'supplier_project_id' => $sp->id,
'platform' => $sp->platform, 'subject_code' => null,
]);
@@ -95,11 +95,11 @@ it('limit comes from snapshot, not live projects.daily_limit_target', function (
'delivered_today' => 7,
]);
$sp = SupplierProject::factory()->create();
\DB::table('project_supplier_links')->insert([
DB::table('project_supplier_links')->insert([
'project_id' => $project->id, 'supplier_project_id' => $sp->id,
'platform' => $sp->platform, 'subject_code' => null,
]);
\DB::table('project_routing_snapshots')->insert([
DB::table('project_routing_snapshots')->insert([
'snapshot_date' => '2026-05-28', 'project_id' => $project->id, 'tenant_id' => $tenant->id,
'daily_limit' => 5, // ← snapshot лимит МЕНЬШЕ чем delivered_today=7
'delivery_days_mask' => 127, 'regions' => '{}',
@@ -2,6 +2,9 @@
declare(strict_types=1);
use App\Models\Project;
use App\Models\Tenant;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
@@ -31,11 +34,11 @@ it('rejects negative daily_limit / expected_volume / delivered_count', function
'delivered_count' => 0,
'created_at' => now(),
]);
})->throws(\Illuminate\Database\QueryException::class);
})->throws(QueryException::class);
it('enforces composite PK (snapshot_date, project_id)', function () {
$tenant = \App\Models\Tenant::factory()->create();
$project = \App\Models\Project::factory()->for($tenant)->create();
$tenant = Tenant::factory()->create();
$project = Project::factory()->for($tenant)->create();
DB::table('project_routing_snapshots')->insert([
'snapshot_date' => '2026-05-28', 'project_id' => $project->id, 'tenant_id' => $tenant->id,
'daily_limit' => 10, 'delivery_days_mask' => 127, 'regions' => '{}',
@@ -48,5 +51,5 @@ it('enforces composite PK (snapshot_date, project_id)', function () {
'daily_limit' => 20, 'delivery_days_mask' => 127, 'regions' => '{}',
'signal_type' => 'call', 'expected_volume' => 20, 'delivered_count' => 0,
'created_at' => now(),
]))->toThrow(\Illuminate\Database\QueryException::class);
]))->toThrow(QueryException::class);
});
@@ -2,6 +2,7 @@
declare(strict_types=1);
use App\Services\MonthlyPartitionManager;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
@@ -78,7 +79,7 @@ test('идемпотентность: повторный запуск не па
// Output второго запуска должен сказать «0 created» по всем партиционированным таблицам × 6 месяцев
// (текущий + ahead=5). Число таблиц берём из PARTITIONED_TABLES — тест не ломается при добавлении новых.
$expectedSkipped = count(\App\Services\MonthlyPartitionManager::PARTITIONED_TABLES) * 6;
$expectedSkipped = count(MonthlyPartitionManager::PARTITIONED_TABLES) * 6;
$output = Artisan::output();
expect($output)->toContain("0 created, {$expectedSkipped} skipped");
});
@@ -6,6 +6,7 @@ use App\Jobs\Supplier\CleanupInactiveSupplierProjectsJob;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Supplier\SupplierPortalClient;
it('does not mark inactive supplier_project that has pivot link to active project', function () {
$tenant = Tenant::factory()->create();
@@ -19,14 +20,14 @@ it('does not mark inactive supplier_project that has pivot link to active projec
$sp = SupplierProject::factory()->create([
'inactive_since' => null,
]);
\DB::table('project_supplier_links')->insert([
DB::table('project_supplier_links')->insert([
'project_id' => $project->id,
'supplier_project_id' => $sp->id,
'platform' => $sp->platform,
'subject_code' => null,
]);
(new CleanupInactiveSupplierProjectsJob)->handle(app(\App\Services\Supplier\SupplierPortalClient::class));
(new CleanupInactiveSupplierProjectsJob)->handle(app(SupplierPortalClient::class));
expect($sp->fresh()->inactive_since)->toBeNull();
});
@@ -35,7 +36,7 @@ it('marks supplier_project inactive when no pivot link exists', function () {
$sp = SupplierProject::factory()->create(['inactive_since' => null]);
// нет project_supplier_links
(new CleanupInactiveSupplierProjectsJob)->handle(app(\App\Services\Supplier\SupplierPortalClient::class));
(new CleanupInactiveSupplierProjectsJob)->handle(app(SupplierPortalClient::class));
expect($sp->fresh()->inactive_since)->not->toBeNull();
});
@@ -7,9 +7,13 @@ use App\Jobs\RouteSupplierLeadJob;
use App\Jobs\Supplier\CsvReconcileJob;
use App\Jobs\Supplier\RefreshSupplierSessionJob;
use App\Mail\CsvDriftAlertMail;
use App\Mail\TenantBusinessDriftAlertMail;
use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\Tenant;
use App\Services\Supplier\SupplierCsvParser;
use App\Services\Supplier\SupplierPortalClient;
use Carbon\Carbon;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Http\Client\Request;
@@ -18,6 +22,7 @@ use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
@@ -352,15 +357,15 @@ it('mixed: 95 matched + 5 junk + 3 real-missing → unparseable_count=5, recover
function insertSnapshotForTenant(int $tenantId, string $date, int $expected, int $delivered): void
{
$tenant = \App\Models\Tenant::find($tenantId) ?? \App\Models\Tenant::factory()->create();
$project = \App\Models\Project::factory()
$tenant = Tenant::find($tenantId) ?? Tenant::factory()->create();
$project = Project::factory()
->for($tenant)
->asCallSignal('7977'.\Illuminate\Support\Str::random(7))
->asCallSignal('7977'.Str::random(7))
->create([
'is_active' => true,
'daily_limit_target' => max($expected, 1),
]);
\Illuminate\Support\Facades\DB::connection('pgsql_supplier')
DB::connection('pgsql_supplier')
->table('project_routing_snapshots')
->insert([
'snapshot_date' => $date,
@@ -380,16 +385,16 @@ function insertSnapshotForTenant(int $tenantId, string $date, int $expected, int
}
it('R-05 business-drift: tenant with shortfall > 20% → TenantBusinessDriftAlertMail sent', function (): void {
$tenant = \App\Models\Tenant::factory()->create();
$tenant = Tenant::factory()->create();
// Yesterday's snapshot: expected 10, delivered 2 → shortfall 80% (>20% threshold).
$yesterday = \Carbon\Carbon::yesterday('Europe/Moscow')->toDateString();
$yesterday = Carbon::yesterday('Europe/Moscow')->toDateString();
insertSnapshotForTenant($tenant->id, $yesterday, 10, 2);
// Empty CSV — primary drift pass is trivially OK; we exercise only the second pass.
fakeReportFlow(csvBody([]));
runCsvReconcile();
Mail::assertSent(\App\Mail\TenantBusinessDriftAlertMail::class, function ($mail) use ($tenant) {
Mail::assertSent(TenantBusinessDriftAlertMail::class, function ($mail) use ($tenant) {
return $mail->tenantId === $tenant->id
&& $mail->expected === 10
&& $mail->delivered === 2
@@ -399,9 +404,9 @@ it('R-05 business-drift: tenant with shortfall > 20% → TenantBusinessDriftAler
});
it('R-05 business-drift: tenant with shortfall <= 20% → NO TenantBusinessDriftAlertMail', function (): void {
$tenant = \App\Models\Tenant::factory()->create();
$tenant = Tenant::factory()->create();
// Yesterday's snapshot: expected 10, delivered 9 → shortfall 10% (<=20% threshold).
$yesterday = \Carbon\Carbon::yesterday('Europe/Moscow')->toDateString();
$yesterday = Carbon::yesterday('Europe/Moscow')->toDateString();
insertSnapshotForTenant($tenant->id, $yesterday, 10, 9);
fakeReportFlow(csvBody([]));
@@ -409,7 +414,7 @@ it('R-05 business-drift: tenant with shortfall <= 20% → NO TenantBusinessDrift
// Scoped assertion: prior-run leaked snapshots may fire mails for other tenants;
// this test only owns one tenant, so assert no mail was sent for IT.
Mail::assertNotSent(\App\Mail\TenantBusinessDriftAlertMail::class, function ($mail) use ($tenant) {
Mail::assertNotSent(TenantBusinessDriftAlertMail::class, function ($mail) use ($tenant) {
return $mail->tenantId === $tenant->id;
});
});
@@ -39,34 +39,33 @@ uses(SharesSupplierPdo::class);
* source_crm_id, привязывает webhook supplier_lead к существующему deal через
* supplier_lead_deliveries, НЕ создаёт второй Deal, НЕ списывает повторно.
*/
beforeEach(function (): void {
$this->seed(PricingTierSeeder::class);
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
// Shared supplier_project для всех тестов (B1, site, domain race-csv.ru).
$this->sp = SupplierProject::factory()->create([
'platform' => 'B1',
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'race-csv.ru',
'unique_key' => 'race-csv.ru',
]);
$this->tenant = Tenant::factory()->create([
'balance_rub' => '10000.00',
'balance_rub' => '10000.00',
'delivered_in_month' => 0,
]);
$this->project = Project::factory()->create([
'tenant_id' => $this->tenant->id,
'signal_type' => 'site',
'signal_identifier' => 'race-csv.ru',
'supplier_b1_project_id' => $this->sp->id,
'is_active' => true,
'daily_limit_target' => 100,
'tenant_id' => $this->tenant->id,
'signal_type' => 'site',
'signal_identifier' => 'race-csv.ru',
'supplier_b1_project_id' => $this->sp->id,
'is_active' => true,
'daily_limit_target' => 100,
'effective_daily_limit_today' => 100,
'delivered_today' => 0,
'delivery_days_mask' => 127,
'region_mask' => 255,
'delivered_today' => 0,
'delivery_days_mask' => 127,
'region_mask' => 255,
]);
linkProjectToSupplier($this->project, $this->sp);
@@ -98,19 +97,19 @@ it('webhook after CSV-recovered merges into existing deal (no duplicate, no doub
// Это то, что CsvReconcileJob создаёт: звонок найден в CSV поставщика,
// но настоящего webhook_log'а нет → вид неизвестен (vid=null).
$csvLead = SupplierLead::factory()->create([
'platform' => 'B1',
'phone' => $phone,
'vid' => null,
'supplier_project_id' => $this->sp->id,
'raw_payload' => [
'platform' => 'B1',
'phone' => $phone,
'vid' => null,
'supplier_project_id' => $this->sp->id,
'raw_payload' => [
'project' => 'B1_race-csv.ru',
'phone' => $phone,
'time' => now()->subHour()->getTimestamp(),
'phone' => $phone,
'time' => now()->subHour()->getTimestamp(),
],
'received_at' => now()->subHour(),
'received_at' => now()->subHour(),
'recovered_from_csv_at' => now()->subHour(),
'source' => 'csv_recovery',
'processed_at' => null,
'source' => 'csv_recovery',
'processed_at' => null,
]);
// RouteSupplierLeadJob обрабатывает CSV-recovered лид → создаёт Deal с source_crm_id=NULL.
@@ -130,18 +129,18 @@ it('webhook after CSV-recovered merges into existing deal (no duplicate, no doub
// Это то, что создаёт дубль на проде: новый SupplierLead с vid != null,
// phone + project те же → RouteSupplierLeadJob создаёт ВТОРОЙ Deal.
$webhookLead = SupplierLead::factory()->create([
'platform' => 'B1',
'phone' => $phone,
'vid' => 1672819986,
'platform' => 'B1',
'phone' => $phone,
'vid' => 1672819986,
'supplier_project_id' => $this->sp->id,
'raw_payload' => [
'vid' => 1672819986,
'raw_payload' => [
'vid' => 1672819986,
'project' => 'B1_race-csv.ru',
'phone' => $phone,
'time' => now()->subMinutes(15)->getTimestamp(),
'phone' => $phone,
'time' => now()->subMinutes(15)->getTimestamp(),
],
'received_at' => now()->subMinutes(15),
'source' => 'webhook',
'received_at' => now()->subMinutes(15),
'source' => 'webhook',
'processed_at' => null,
]);
@@ -184,36 +183,36 @@ it('two webhooks with DIFFERENT vids both create deals (Spec B — за повт
// Первый webhook, vid=100.
$lead1 = SupplierLead::factory()->create([
'platform' => 'B1',
'phone' => $phone,
'vid' => 100,
'platform' => 'B1',
'phone' => $phone,
'vid' => 100,
'supplier_project_id' => $this->sp->id,
'raw_payload' => [
'vid' => 100,
'raw_payload' => [
'vid' => 100,
'project' => 'B1_race-csv.ru',
'phone' => $phone,
'time' => now()->subHour()->getTimestamp(),
'phone' => $phone,
'time' => now()->subHour()->getTimestamp(),
],
'received_at' => now()->subHour(),
'source' => 'webhook',
'received_at' => now()->subHour(),
'source' => 'webhook',
'processed_at' => null,
]);
runRaceJob($lead1->id);
// Второй webhook, vid=200 (другой лид поставщика, тот же телефон+проект).
$lead2 = SupplierLead::factory()->create([
'platform' => 'B1',
'phone' => $phone,
'vid' => 200,
'platform' => 'B1',
'phone' => $phone,
'vid' => 200,
'supplier_project_id' => $this->sp->id,
'raw_payload' => [
'vid' => 200,
'raw_payload' => [
'vid' => 200,
'project' => 'B1_race-csv.ru',
'phone' => $phone,
'time' => now()->subMinutes(30)->getTimestamp(),
'phone' => $phone,
'time' => now()->subMinutes(30)->getTimestamp(),
],
'received_at' => now()->subMinutes(30),
'source' => 'webhook',
'received_at' => now()->subMinutes(30),
'source' => 'webhook',
'processed_at' => null,
]);
runRaceJob($lead2->id);
@@ -240,19 +239,19 @@ it('csv-recovered deal older than 24h is NOT merged with new webhook', function
// CSV-recovered SupplierLead, обработанный 2 дня назад.
$csvLead = SupplierLead::factory()->create([
'platform' => 'B1',
'phone' => $phone,
'vid' => null,
'supplier_project_id' => $this->sp->id,
'raw_payload' => [
'platform' => 'B1',
'phone' => $phone,
'vid' => null,
'supplier_project_id' => $this->sp->id,
'raw_payload' => [
'project' => 'B1_race-csv.ru',
'phone' => $phone,
'time' => now()->subDays(2)->getTimestamp(),
'phone' => $phone,
'time' => now()->subDays(2)->getTimestamp(),
],
'received_at' => now()->subDays(2),
'received_at' => now()->subDays(2),
'recovered_from_csv_at' => now()->subDays(2),
'source' => 'csv_recovery',
'processed_at' => null,
'source' => 'csv_recovery',
'processed_at' => null,
]);
runRaceJob($csvLead->id);
@@ -266,18 +265,18 @@ it('csv-recovered deal older than 24h is NOT merged with new webhook', function
// Webhook приходит сейчас — deal CSV-recovery старше 24h → не мержится.
$webhookLead = SupplierLead::factory()->create([
'platform' => 'B1',
'phone' => $phone,
'vid' => 999,
'platform' => 'B1',
'phone' => $phone,
'vid' => 999,
'supplier_project_id' => $this->sp->id,
'raw_payload' => [
'vid' => 999,
'raw_payload' => [
'vid' => 999,
'project' => 'B1_race-csv.ru',
'phone' => $phone,
'time' => now()->getTimestamp(),
'phone' => $phone,
'time' => now()->getTimestamp(),
],
'received_at' => now(),
'source' => 'webhook',
'received_at' => now(),
'source' => 'webhook',
'processed_at' => null,
]);
runRaceJob($webhookLead->id);
@@ -7,7 +7,6 @@ use App\Models\Deal;
use App\Models\Project;
use App\Models\Supplier;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\SystemSetting;
use App\Models\Tenant;
use App\Services\Billing\LedgerService;
@@ -41,7 +40,6 @@ uses(SharesSupplierPdo::class);
*
* Spec: docs/superpowers/specs/2026-05-25-supplier-webhook-reliability-design.md §3 Phase 3
*/
beforeEach(function (): void {
$this->seed(PricingTierSeeder::class);
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
@@ -10,6 +10,7 @@ use App\Services\Supplier\SupplierPortalClient;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
@@ -275,13 +276,13 @@ test('R-17 commit creates SMS supplier_projects with UNIFORM unique_key=sender+k
$tenant = Tenant::factory()->create();
$sender = '7903'.fake()->numerify('#######');
$keyword = 'TASKR17_'.\Illuminate\Support\Str::random(5);
$keyword = 'TASKR17_'.Str::random(5);
// SMS group with keyword: only B2 + B3 (no B1 — CHECK constraint chk_supplier_projects_b1_not_for_sms).
// Content format: 'sender+keyword' for B2 (src='bl'), 'sender' for B3 (src='mt') — supplier portal convention.
$importer = importerWithRows([
['id' => '9101', 'src' => 'bl', 'type' => 'sms', 'content' => $sender.'+'.$keyword, 'tag' => 'СМС', 'lim' => '5', 'status' => true, 'regions' => '', 'workdays' => ['1','2','3','4','5']],
['id' => '9102', 'src' => 'mt', 'type' => 'sms', 'content' => $sender, 'tag' => 'СМС', 'lim' => '5', 'status' => true, 'regions' => '', 'workdays' => ['1','2','3','4','5']],
['id' => '9101', 'src' => 'bl', 'type' => 'sms', 'content' => $sender.'+'.$keyword, 'tag' => 'СМС', 'lim' => '5', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3', '4', '5']],
['id' => '9102', 'src' => 'mt', 'type' => 'sms', 'content' => $sender, 'tag' => 'СМС', 'lim' => '5', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3', '4', '5']],
]);
$plan = $importer->buildPlan($tenant->id);
$importer->commit($plan, $tenant->id);
+2 -1
View File
@@ -26,7 +26,8 @@ beforeEach(() => {
status: r.status === 'overdue' ? 'active' : r.status,
balance_rub: String(r.balance_rub),
tariff_id: 1,
tariff_name: { start: 'Старт', basic: 'Базовый', pro: 'Команда', enterprise: 'Enterprise' }[r.tariff] ?? r.tariff,
tariff_name:
{ start: 'Старт', basic: 'Базовый', pro: 'Команда', enterprise: 'Enterprise' }[r.tariff] ?? r.tariff,
mrr_rub: String(r.mrr_rub),
monthly_topups_rub: String(r.monthly_topups_rub),
monthly_charges_rub: String(r.monthly_charges_rub),
@@ -127,9 +127,7 @@ describe('AdminBillingView — row-actions menu (G4)', () => {
});
it('confirmAction() вызывает refundTenant с суммой и причиной', async () => {
vi.mocked(adminApi.listAdminBilling).mockResolvedValue(
makeBillingResponse([makeApiBillingTenant({ id: 42 })]),
);
vi.mocked(adminApi.listAdminBilling).mockResolvedValue(makeBillingResponse([makeApiBillingTenant({ id: 42 })]));
vi.mocked(adminApi.refundTenant).mockResolvedValueOnce({
id: 42,
balance_rub: '4500.00',
@@ -179,9 +177,7 @@ describe('AdminBillingView — row-actions menu (G4)', () => {
});
it('confirmAction("tariff") вызывает changeTenantTariff', async () => {
vi.mocked(adminApi.listAdminBilling).mockResolvedValue(
makeBillingResponse([makeApiBillingTenant({ id: 42 })]),
);
vi.mocked(adminApi.listAdminBilling).mockResolvedValue(makeBillingResponse([makeApiBillingTenant({ id: 42 })]));
vi.mocked(adminApi.listAdminTariffPlans).mockResolvedValueOnce([
{ id: 2, name: 'Команда', price_monthly: '990.00' },
]);
@@ -105,12 +105,16 @@ describe('AdminIncidentDetailView.vue', () => {
expect(wrapper.find('[data-testid="incident-fetch-error"]').exists()).toBe(true);
// retry button calls loadIncident
vi.mocked(adminApi.getAdminIncidentDetail).mockResolvedValue(makeDetail());
const retryBtn = wrapper.find('[data-testid="incident-fetch-error"] button, [data-testid="incident-fetch-error"] .v-btn');
const retryBtn = wrapper.find(
'[data-testid="incident-fetch-error"] button, [data-testid="incident-fetch-error"] .v-btn',
);
expect(retryBtn.exists()).toBe(true);
});
it('data_breach + rkn_notified=false → data-testid="rkn-notify-btn" видна', async () => {
vi.mocked(adminApi.getAdminIncidentDetail).mockResolvedValue(makeDetail({ type: 'data_breach', rkn_notified: false }));
vi.mocked(adminApi.getAdminIncidentDetail).mockResolvedValue(
makeDetail({ type: 'data_breach', rkn_notified: false }),
);
const wrapper = await mountDetail(7);
expect(wrapper.find('[data-testid="rkn-notify-btn"]').exists()).toBe(true);
});
@@ -119,7 +123,11 @@ describe('AdminIncidentDetailView.vue', () => {
vi.mocked(adminApi.getAdminIncidentDetail).mockResolvedValue(
makeDetail({ type: 'data_breach', rkn_notified: false }),
);
const notified = makeDetail({ type: 'data_breach', rkn_notified: true, rkn_notified_at: '2026-05-16T11:00:00Z' });
const notified = makeDetail({
type: 'data_breach',
rkn_notified: true,
rkn_notified_at: '2026-05-16T11:00:00Z',
});
vi.mocked(adminApi.notifyIncidentRkn).mockResolvedValue(notified);
const wrapper = await mountDetail(7);
@@ -162,9 +170,7 @@ describe('AdminIncidentDetailView.vue', () => {
vi.mocked(adminApi.getAdminIncidentDetail).mockResolvedValue(
makeDetail({ type: 'data_breach', rkn_notified: false }),
);
vi.mocked(adminApi.notifyIncidentRkn).mockRejectedValue(
new Error('РКН endpoint недоступен'),
);
vi.mocked(adminApi.notifyIncidentRkn).mockRejectedValue(new Error('РКН endpoint недоступен'));
const wrapper = await mountDetail(7);
const vm = wrapper.vm as unknown as {
@@ -43,9 +43,6 @@ describe('AdminSupplierIntegrationView — export-mode toggle (Plan 4 Task 1)',
await onlineBtn.trigger('click');
await new Promise((r) => setTimeout(r, 20));
expect(axios.post).toHaveBeenCalledWith(
'/api/admin/supplier-integration/export-mode',
{ mode: 'online' },
);
expect(axios.post).toHaveBeenCalledWith('/api/admin/supplier-integration/export-mode', { mode: 'online' });
});
});
@@ -57,8 +57,6 @@ describe('AdminSupplierIntegrationView — manual queue section', () => {
expect(btn.exists()).toBe(true);
await btn.trigger('click');
expect(axios.post).toHaveBeenCalledWith(
expect.stringContaining('/manual-queue/1/resolve'),
);
expect(axios.post).toHaveBeenCalledWith(expect.stringContaining('/manual-queue/1/resolve'));
});
});
@@ -12,9 +12,15 @@ const healthPayload = {
health: { last_run_at: '2026-05-18T12:00:00Z', last_status: 'ok', drift_ratio: 0.02, webhook_state: 'live' },
history: [
{
started_at: '2026-05-18T12:00:00Z', finished_at: '2026-05-18T12:01:00Z',
window_start: '2026-05-17T00:00:00Z', window_end: '2026-05-18T12:00:00Z',
status: 'ok', total_csv_rows: 100, matched_count: 98, recovered_count: 2, drift_ratio: 0.02,
started_at: '2026-05-18T12:00:00Z',
finished_at: '2026-05-18T12:01:00Z',
window_start: '2026-05-17T00:00:00Z',
window_end: '2026-05-18T12:00:00Z',
status: 'ok',
total_csv_rows: 100,
matched_count: 98,
recovered_count: 2,
drift_ratio: 0.02,
},
],
};
@@ -107,12 +107,17 @@ describe('AdminSupplierPricesView error handling (Sprint 1 G2)', () => {
vi.mocked(adminApi.getAdminSuppliers).mockResolvedValue([
{ id: 1, code: 'B1', name: 'Supplier 1', cost_rub: '120.00', quality_score: '8.50', is_active: true },
]);
vi.mocked(adminApi.updateAdminSupplier).mockRejectedValue(
makeAxiosError('cost_rub must be non-negative', 422),
);
vi.mocked(adminApi.updateAdminSupplier).mockRejectedValue(makeAxiosError('cost_rub must be non-negative', 422));
const wrapper = mount(AdminSupplierPricesView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
const row = { id: 1, code: 'B1', name: 'Supplier 1', cost_rub: '-5.00', quality_score: '8.50', is_active: true };
const row = {
id: 1,
code: 'B1',
name: 'Supplier 1',
cost_rub: '-5.00',
quality_score: '8.50',
is_active: true,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (wrapper.vm as any).save(row);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -125,12 +130,24 @@ describe('AdminSupplierPricesView error handling (Sprint 1 G2)', () => {
vi.mocked(adminApi.getAdminSuppliers).mockResolvedValue([
{ id: 2, code: 'B2', name: 'Supplier 2', cost_rub: '100.00', quality_score: '9.00', is_active: true },
]);
vi.mocked(adminApi.updateAdminSupplier).mockResolvedValue(
{ id: 2, code: 'B2', name: 'Supplier 2', cost_rub: '110.00', quality_score: '9.00', is_active: true },
);
vi.mocked(adminApi.updateAdminSupplier).mockResolvedValue({
id: 2,
code: 'B2',
name: 'Supplier 2',
cost_rub: '110.00',
quality_score: '9.00',
is_active: true,
});
const wrapper = mount(AdminSupplierPricesView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
const row = { id: 2, code: 'B2', name: 'Supplier 2', cost_rub: '110.00', quality_score: '9.00', is_active: true };
const row = {
id: 2,
code: 'B2',
name: 'Supplier 2',
cost_rub: '110.00',
quality_score: '9.00',
is_active: true,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (wrapper.vm as any).save(row);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -142,9 +159,7 @@ describe('AdminSupplierPricesView error handling (Sprint 1 G2)', () => {
});
it('load() sets fetchError when getAdminSuppliers rejects', async () => {
vi.mocked(adminApi.getAdminSuppliers).mockRejectedValue(
makeAxiosError('Database connection lost', 500),
);
vi.mocked(adminApi.getAdminSuppliers).mockRejectedValue(makeAxiosError('Database connection lost', 500));
const wrapper = mount(AdminSupplierPricesView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -157,17 +172,27 @@ describe('AdminSupplierPricesView error handling (Sprint 1 G2)', () => {
{ id: 3, code: 'B3', name: 'Supplier 3', cost_rub: '100.00', quality_score: '8.00', is_active: true },
]);
// First call fails
vi.mocked(adminApi.updateAdminSupplier).mockRejectedValueOnce(
makeAxiosError('transient', 500),
);
vi.mocked(adminApi.updateAdminSupplier).mockRejectedValueOnce(makeAxiosError('transient', 500));
// Second call succeeds
vi.mocked(adminApi.updateAdminSupplier).mockResolvedValueOnce(
{ id: 3, code: 'B3', name: 'Supplier 3', cost_rub: '100.00', quality_score: '8.00', is_active: true },
);
vi.mocked(adminApi.updateAdminSupplier).mockResolvedValueOnce({
id: 3,
code: 'B3',
name: 'Supplier 3',
cost_rub: '100.00',
quality_score: '8.00',
is_active: true,
});
const wrapper = mount(AdminSupplierPricesView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
const row = { id: 3, code: 'B3', name: 'Supplier 3', cost_rub: '100.00', quality_score: '8.00', is_active: true };
const row = {
id: 3,
code: 'B3',
name: 'Supplier 3',
cost_rub: '100.00',
quality_score: '8.00',
is_active: true,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (wrapper.vm as any).save(row);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -65,10 +65,7 @@ describe('AdminSupplierProjectsView (Plan 4 Task 3)', () => {
await wrapper.find('[data-testid="confirm-delete-btn"]').trigger('click');
await flushPromises();
expect(axios.post).toHaveBeenCalledWith(
'/api/admin/supplier-integration/projects/delete',
{ ids: [1] },
);
expect(axios.post).toHaveBeenCalledWith('/api/admin/supplier-integration/projects/delete', { ids: [1] });
});
it('bulk-delete button is disabled when nothing selected', async () => {
+16 -2
View File
@@ -41,7 +41,14 @@ describe('ApiTab.vue', () => {
it('загружает и показывает префикс API-ключа', async () => {
(apiKeysApi.listApiKeys as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 1, name: 'API-ключ', key_prefix: 'lpkapi_abc', last_used_at: null, expires_at: null, created_at: null },
{
id: 1,
name: 'API-ключ',
key_prefix: 'lpkapi_abc',
last_used_at: null,
expires_at: null,
created_at: null,
},
]);
const wrapper = mountTab();
await flush();
@@ -53,7 +60,14 @@ describe('ApiTab.vue', () => {
it('copyToken() пишет в буфер и открывает toast', async () => {
(apiKeysApi.listApiKeys as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 1, name: 'API-ключ', key_prefix: 'lpkapi_abc', last_used_at: null, expires_at: null, created_at: null },
{
id: 1,
name: 'API-ключ',
key_prefix: 'lpkapi_abc',
last_used_at: null,
expires_at: null,
created_at: null,
},
]);
const wrapper = mountTab();
await flush();
+1 -3
View File
@@ -98,9 +98,7 @@ describe('BillingView.vue', () => {
it('кнопка «Повторить» перезагружает кошелёк после ошибки', async () => {
vi.mocked(billingApi.getWallet).mockReset();
vi.mocked(billingApi.getWallet)
.mockRejectedValueOnce(new Error('network'))
.mockResolvedValueOnce(makeWallet());
vi.mocked(billingApi.getWallet).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce(makeWallet());
const wrapper = factory();
await flushPromises();
expect(wrapper.text()).toContain('Не удалось загрузить');
+33 -20
View File
@@ -9,11 +9,18 @@ const vuetify = createVuetify();
function makeDeal(overrides: Partial<MockDeal> = {}): MockDeal {
return {
id: 1, name: '+79991234567', phone: '+79991234567', statusSlug: 'new',
project: 'p', manager: { initials: 'AD', name: 'Admin' }, cost: 0,
id: 1,
name: '+79991234567',
phone: '+79991234567',
statusSlug: 'new',
project: 'p',
manager: { initials: 'AD', name: 'Admin' },
cost: 0,
receivedMinutesAgo: 1,
projectSignalType: 'site', projectSignalIdentifier: 'krk-finance.ru',
projectSmsKeyword: null, projectSmsSenders: null,
projectSignalType: 'site',
projectSignalIdentifier: 'krk-finance.ru',
projectSmsKeyword: null,
projectSmsSenders: null,
...overrides,
};
}
@@ -32,10 +39,12 @@ describe('DealDetailBody — Тип и Источник (18.05.2026 ux)', () =>
it('call: Тип «Звонок» и Источник = телефонный номер', () => {
setActivePinia(createPinia());
const w = mount(DealDetailBody, {
props: { deal: makeDeal({
projectSignalType: 'call',
projectSignalIdentifier: '79992223344',
}) },
props: {
deal: makeDeal({
projectSignalType: 'call',
projectSignalIdentifier: '79992223344',
}),
},
global: { plugins: [vuetify] },
});
expect(w.text()).toContain('Звонок');
@@ -45,12 +54,14 @@ describe('DealDetailBody — Тип и Источник (18.05.2026 ux)', () =>
it('sms с keyword: Источник = «sender (KEYWORD)»', () => {
setActivePinia(createPinia());
const w = mount(DealDetailBody, {
props: { deal: makeDeal({
projectSignalType: 'sms',
projectSignalIdentifier: null,
projectSmsSenders: ['MTS', 'BEELINE'],
projectSmsKeyword: 'КРЕДИТ',
}) },
props: {
deal: makeDeal({
projectSignalType: 'sms',
projectSignalIdentifier: null,
projectSmsSenders: ['MTS', 'BEELINE'],
projectSmsKeyword: 'КРЕДИТ',
}),
},
global: { plugins: [vuetify] },
});
expect(w.text()).toContain('СМС');
@@ -60,12 +71,14 @@ describe('DealDetailBody — Тип и Источник (18.05.2026 ux)', () =>
it('sms без keyword: Источник = только sender', () => {
setActivePinia(createPinia());
const w = mount(DealDetailBody, {
props: { deal: makeDeal({
projectSignalType: 'sms',
projectSignalIdentifier: null,
projectSmsSenders: ['MTS'],
projectSmsKeyword: null,
}) },
props: {
deal: makeDeal({
projectSignalType: 'sms',
projectSignalIdentifier: null,
projectSmsSenders: ['MTS'],
projectSmsKeyword: null,
}),
},
global: { plugins: [vuetify] },
});
expect(w.text()).toContain('СМС');
+8 -2
View File
@@ -7,8 +7,14 @@ import type { MockDeal } from '../../resources/js/composables/mockDeals';
const vuetify = createVuetify();
const deal: MockDeal = {
id: 1, name: '+7 999', phone: '+7 999', statusSlug: 'new', project: 'Окна',
manager: { initials: 'AD', name: 'Admin' }, cost: 0, receivedMinutesAgo: 5,
id: 1,
name: '+7 999',
phone: '+7 999',
statusSlug: 'new',
project: 'Окна',
manager: { initials: 'AD', name: 'Admin' },
cost: 0,
receivedMinutesAgo: 5,
};
function mountDrawer(props: Record<string, unknown>) {
+9 -3
View File
@@ -15,9 +15,15 @@ const statuses: LeadStatus[] = [
function makeDeal(over: Partial<MockDeal> = {}): MockDeal {
return {
id: 1, name: '+79991234567', phone: '+79991234567', statusSlug: 'new',
project: 'p', manager: { initials: 'A', name: 'A' }, cost: 0,
receivedMinutesAgo: 1, ...over,
id: 1,
name: '+79991234567',
phone: '+79991234567',
statusSlug: 'new',
project: 'p',
manager: { initials: 'A', name: 'A' },
cost: 0,
receivedMinutesAgo: 1,
...over,
};
}
+27 -9
View File
@@ -8,15 +8,33 @@ const vuetify = createVuetify();
const sampleDeals: MockDeal[] = [
{
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: 'звонил', receivedAt: '2026-05-15T09:00:00+00:00',
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: 'звонил',
receivedAt: '2026-05-15T09:00:00+00:00',
nextReminderAt: '2026-05-18T07:00:00+00:00',
},
{
id: 2, name: '+7 (916) 100-00-02', phone: '+7 (916) 100-00-02', statusSlug: 'new',
project: 'Двери', manager: { initials: 'AD', name: 'Admin' }, cost: 0, receivedMinutesAgo: 30,
signalType: 'site', city: null, comment: null, receivedAt: '2026-05-14T09:00:00+00:00',
id: 2,
name: '+7 (916) 100-00-02',
phone: '+7 (916) 100-00-02',
statusSlug: 'new',
project: 'Двери',
manager: { initials: 'AD', name: 'Admin' },
cost: 0,
receivedMinutesAgo: 30,
signalType: 'site',
city: null,
comment: null,
receivedAt: '2026-05-14T09:00:00+00:00',
nextReminderAt: null,
},
];
@@ -46,9 +64,9 @@ describe('DealsTable', () => {
props: { deals: sampleDeals, selectedIds: [], statusBySlug: new Map() },
global: { plugins: [vuetify] },
});
expect(
w.find('th .v-selection-control input[type="checkbox"][aria-label="Выбрать все сделки"]').exists(),
).toBe(true);
expect(w.find('th .v-selection-control input[type="checkbox"][aria-label="Выбрать все сделки"]').exists()).toBe(
true,
);
});
it('клик по строке эмитит row-click с deal', async () => {
+32 -12
View File
@@ -11,10 +11,21 @@ import type { MockDeal } from '../../resources/js/composables/mockDeals';
function apiDeal(id: number, over: Partial<dealsApi.ApiDeal> = {}): dealsApi.ApiDeal {
return {
id, tenant_id: 42, project_id: 1, project_name: 'Окна', phone: `+7 916 000-00-0${id}`,
contact_name: null, status: 'new', manager_id: null, manager_name: null,
manager_initials: null, received_at: '2026-05-15T09:00:00+00:00',
comment: null, city: null, project_signal_type: 'call', next_reminder_at: null,
id,
tenant_id: 42,
project_id: 1,
project_name: 'Окна',
phone: `+7 916 000-00-0${id}`,
contact_name: null,
status: 'new',
manager_id: null,
manager_name: null,
manager_initials: null,
received_at: '2026-05-15T09:00:00+00:00',
comment: null,
city: null,
project_signal_type: 'call',
next_reminder_at: null,
...over,
};
}
@@ -24,9 +35,7 @@ async function mountDeals(deals: dealsApi.ApiDeal[] = [apiDeal(1), apiDeal(2)],
const auth = useAuthStore();
auth.user = { id: 1, tenant_id: 42, email: 't@t.com' } as AuthUser;
const dealsSpy = vi.spyOn(dealsApi, 'listDeals').mockResolvedValue({ deals, total, limit: 20, offset: 0 });
vi.spyOn(dealsApi, 'listProjects').mockResolvedValue([
{ id: 1, name: 'Окна', tag: null, type: 'supplier' },
]);
vi.spyOn(dealsApi, 'listProjects').mockResolvedValue([{ id: 1, name: 'Окна', tag: null, type: 'supplier' }]);
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/deals', component: DealsView }],
@@ -82,7 +91,9 @@ describe('DealsView.vue — реестр лидов', () => {
it('openPanel выбирает сделку, повторный клик закрывает', async () => {
const w = await mountDeals();
const vm = w.vm as unknown as {
dealsState: MockDeal[]; panelOpen: boolean; selectedDeal: MockDeal | null;
dealsState: MockDeal[];
panelOpen: boolean;
selectedDeal: MockDeal | null;
openPanel: (d: MockDeal) => void;
};
vm.openPanel(vm.dealsState[0]);
@@ -95,7 +106,9 @@ describe('DealsView.vue — реестр лидов', () => {
it('при selected=1 drawer авто-открывается, bulk-полоса скрыта (18.05.2026 ux)', async () => {
const w = await mountDeals();
const vm = w.vm as unknown as {
selected: number[]; panelOpen: boolean; selectedDeal: MockDeal | null;
selected: number[];
panelOpen: boolean;
selectedDeal: MockDeal | null;
};
vm.selected = [1];
await flushPromises();
@@ -107,7 +120,9 @@ describe('DealsView.vue — реестр лидов', () => {
it('при selected≥2 drawer закрывается, bulk-полоса видна (18.05.2026 ux)', async () => {
const w = await mountDeals();
const vm = w.vm as unknown as {
selected: number[]; panelOpen: boolean; dealsState: MockDeal[];
selected: number[];
panelOpen: boolean;
dealsState: MockDeal[];
openPanel: (d: MockDeal) => void;
};
vm.openPanel(vm.dealsState[0]);
@@ -121,7 +136,9 @@ describe('DealsView.vue — реестр лидов', () => {
it('bulk-bar появляется при выборе и applyBulkStatus меняет статус', async () => {
const w = await mountDeals();
const vm = w.vm as unknown as {
selected: number[]; dealsState: MockDeal[]; applyBulkStatus: (s: string) => Promise<void>;
selected: number[];
dealsState: MockDeal[];
applyBulkStatus: (s: string) => Promise<void>;
};
vi.spyOn(dealsApi, 'transitionDeals').mockResolvedValue({ updated: 2, requested: 2, status: 'viewed' });
vm.selected = [1, 2];
@@ -165,7 +182,10 @@ describe('DealsView.vue — реестр лидов', () => {
auth.user = { id: 1, tenant_id: 42, email: 't@t.com' } as AuthUser;
vi.spyOn(dealsApi, 'listDeals').mockRejectedValue(new Error('500'));
vi.spyOn(dealsApi, 'listProjects').mockResolvedValue([]);
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/deals', component: DealsView }] });
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/deals', component: DealsView }],
});
await router.push('/deals');
await router.isReady();
const w = mount(DealsView, {
@@ -214,9 +214,9 @@ describe('ImpersonationDialog.vue', () => {
});
const wrapper = factory({ modelValue: true, tenant: sampleTenant });
await wrapper.find('[data-testid="reason-input"] textarea').setValue(
'Тикет SUP-12453: клиент сообщил, что в карточке сделки не сохраняется коммент.',
);
await wrapper
.find('[data-testid="reason-input"] textarea')
.setValue('Тикет SUP-12453: клиент сообщил, что в карточке сделки не сохраняется коммент.');
await wrapper.find('[data-testid="submit-init-btn"]').trigger('click');
await flushPromises();
+1 -3
View File
@@ -61,9 +61,7 @@ describe('ImportView', () => {
});
it('показывает баннер о неизвестных статусах', async () => {
vi.spyOn(importsApi, 'getUnknownStatuses').mockResolvedValue([
{ id: 1, status_ru: 'Архив', occurrences: 3 },
]);
vi.spyOn(importsApi, 'getUnknownStatuses').mockResolvedValue([{ id: 1, status_ru: 'Архив', occurrences: 3 }]);
const wrapper = mountView();
await flushPromises();
+12 -4
View File
@@ -37,12 +37,20 @@ describe('InvoicesTable.vue', () => {
it('PDF-кнопка disabled при has_pdf=false и активна при has_pdf=true', async () => {
const invs: BillingInvoice[] = [
{
id: 1, invoice_number: 'СЧ-2026-00010', amount_total: '990.00',
status: 'issued', issued_at: '2026-05-07T00:00:00Z', has_pdf: false,
id: 1,
invoice_number: 'СЧ-2026-00010',
amount_total: '990.00',
status: 'issued',
issued_at: '2026-05-07T00:00:00Z',
has_pdf: false,
},
{
id: 2, invoice_number: 'СЧ-2026-00011', amount_total: '500.00',
status: 'paid', issued_at: '2026-05-08T00:00:00Z', has_pdf: true,
id: 2,
invoice_number: 'СЧ-2026-00011',
amount_total: '500.00',
status: 'paid',
issued_at: '2026-05-08T00:00:00Z',
has_pdf: true,
},
];
vi.mocked(billingApi.getInvoices).mockResolvedValue({ data: invs });
+33 -4
View File
@@ -172,7 +172,16 @@ describe('KanbanView DnD persist (Sprint 1 C4)', () => {
auth.user = { id: 99, tenant_id: 7, email: 'demo@demo.local' } as never;
await new Promise((r) => setTimeout(r, 30));
const deal = { id: 42, statusSlug: 'new' as const, name: 'X', phone: '+79161234567', project: 'p', manager: { name: 'M', initials: 'M' }, cost: 100, receivedMinutesAgo: 5 };
const deal = {
id: 42,
statusSlug: 'new' as const,
name: 'X',
phone: '+79161234567',
project: 'p',
manager: { name: 'M', initials: 'M' },
cost: 100,
receivedMinutesAgo: 5,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (wrapper.vm as any).onColumnChange('in_progress', { added: { element: deal, newIndex: 0 } });
@@ -196,7 +205,16 @@ describe('KanbanView DnD persist (Sprint 1 C4)', () => {
auth.user = { id: 99, tenant_id: 7, email: 'demo@demo.local' } as never;
await new Promise((r) => setTimeout(r, 30));
const deal = { id: 43, statusSlug: 'new' as const, name: 'Y', phone: '+79161234567', project: 'p', manager: { name: 'M', initials: 'M' }, cost: 100, receivedMinutesAgo: 5 };
const deal = {
id: 43,
statusSlug: 'new' as const,
name: 'Y',
phone: '+79161234567',
project: 'p',
manager: { name: 'M', initials: 'M' },
cost: 100,
receivedMinutesAgo: 5,
};
// Имитируем vuedraggable mutation: карточка уже в target column до вызова onColumnChange.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
@@ -218,7 +236,9 @@ describe('KanbanView DnD persist (Sprint 1 C4)', () => {
it('onColumnChange skips API call if no auth.user.tenant_id', async () => {
const transitionSpy = vi.spyOn(dealsApi, 'transitionDeals').mockResolvedValue({
updated: 1, requested: 1, status: 'in_progress',
updated: 1,
requested: 1,
status: 'in_progress',
});
const wrapper = mount(KanbanView, {
global: {
@@ -230,7 +250,16 @@ describe('KanbanView DnD persist (Sprint 1 C4)', () => {
auth.user = null;
await new Promise((r) => setTimeout(r, 30));
const deal = { id: 44, statusSlug: 'new' as const, name: 'Z', phone: '+79161234567', project: 'p', manager: { name: 'M', initials: 'M' }, cost: 100, receivedMinutesAgo: 5 };
const deal = {
id: 44,
statusSlug: 'new' as const,
name: 'Z',
phone: '+79161234567',
project: 'p',
manager: { name: 'M', initials: 'M' },
cost: 100,
receivedMinutesAgo: 5,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (wrapper.vm as any).onColumnChange('in_progress', { added: { element: deal, newIndex: 0 } });
+1 -3
View File
@@ -83,9 +83,7 @@ describe('ProfileTab.vue', () => {
const vm = wrapper.vm as any;
vm.phone = ' ';
await vm.save();
expect(authApi.updateProfile).toHaveBeenCalledWith(
expect.objectContaining({ phone: null }),
);
expect(authApi.updateProfile).toHaveBeenCalledWith(expect.objectContaining({ phone: null }));
});
it('save() показывает ошибку при reject', async () => {
@@ -181,7 +181,8 @@ describe('ProjectDetailsDrawer', () => {
it('Delete: 422 errors.project → drawer не закрывается, текст показан', async () => {
const wrapper = mount(ProjectDetailsDrawer, { props: { project: sampleProject } });
const store = useProjectsStore();
const message = 'Мы уже начали сбор лидов по этому проекту на завтра. Пока поставьте на паузу — мы увидим это сегодня в 18:00 и завтра не будем запускать сбор лидов по этому проекту. Удалить можно будет послезавтра.';
const message =
'Мы уже начали сбор лидов по этому проекту на завтра. Пока поставьте на паузу — мы увидим это сегодня в 18:00 и завтра не будем запускать сбор лидов по этому проекту. Удалить можно будет послезавтра.';
vi.spyOn(store, 'del').mockRejectedValueOnce({
response: { status: 422, data: { errors: { project: [message] } } },
});
@@ -203,7 +204,13 @@ describe('ProjectDetailsDrawer', () => {
(axios.patch as unknown as ReturnType<typeof vi.fn>).mockRejectedValueOnce({
response: {
status: 422,
data: { errors: { project: ['Мы уже начали сбор лидов по этому проекту на завтра. Изменить источник можно будет послезавтра.'] } },
data: {
errors: {
project: [
'Мы уже начали сбор лидов по этому проекту на завтра. Изменить источник можно будет послезавтра.',
],
},
},
},
});
const wrapper = mount(ProjectDetailsDrawer, { props: { project: sampleProject } });
+1 -5
View File
@@ -42,11 +42,7 @@ const mockReminder = (overrides: Partial<ApiReminder> = {}): ApiReminder => ({
});
// VDialog в JSDOM teleport'ится — стаб делает <slot/> рендеримым inline.
const factory = (props: {
modelValue: boolean;
dealId?: number | null;
reminder?: ApiReminder | null;
}) =>
const factory = (props: { modelValue: boolean; dealId?: number | null; reminder?: ApiReminder | null }) =>
mount(ReminderDialog, {
props,
global: {
+1 -3
View File
@@ -44,9 +44,7 @@ describe('TransactionsTable.vue', () => {
const wrapper = mount(TransactionsTable, { global: { plugins: [vuetify] } });
await flushPromises();
await (wrapper.vm as unknown as { changeTab: (id: string) => Promise<void> }).changeTab('topup');
expect(billingApi.getTransactions).toHaveBeenLastCalledWith(
expect.objectContaining({ type: 'topup' }),
);
expect(billingApi.getTransactions).toHaveBeenLastCalledWith(expect.objectContaining({ type: 'topup' }));
});
it('таб «Все» не шлёт type', async () => {
+4 -7
View File
@@ -320,10 +320,7 @@ describe('api/admin', () => {
};
const r = await updateSystemSetting('lead.price_default', payload);
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
expect(apiClient.put).toHaveBeenCalledWith(
'/api/admin/system-settings/lead.price_default',
payload,
);
expect(apiClient.put).toHaveBeenCalledWith('/api/admin/system-settings/lead.price_default', payload);
const csrfOrder = vi.mocked(ensureCsrfCookie).mock.invocationCallOrder[0];
const putOrder = vi.mocked(apiClient.put).mock.invocationCallOrder[0];
expect(csrfOrder).toBeLessThan(putOrder);
@@ -349,9 +346,9 @@ describe('api/admin', () => {
it('impersonationInit() пробрасывает ошибку из apiClient.post (не глотает)', async () => {
vi.mocked(apiClient.post).mockRejectedValueOnce(new Error('Network'));
await expect(
impersonationInit({ tenant_id: 1, requested_by: 9, reason: 'r'.repeat(30) }),
).rejects.toThrow('Network');
await expect(impersonationInit({ tenant_id: 1, requested_by: 9, reason: 'r'.repeat(30) })).rejects.toThrow(
'Network',
);
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
});
+6 -1
View File
@@ -171,7 +171,12 @@ describe('api/deals', () => {
it('listProjects() GET /api/projects + unwraps { data: [...] } (JsonResource collection)', async () => {
// ProjectController::index() отдаёт response()->json(['data' => ProjectResource::collection(...)]).
vi.mocked(apiClient.get).mockResolvedValue({
data: { data: [{ id: 1, name: 'B1_Окна СПб' }, { id: 2, name: 'B2_Двери' }] },
data: {
data: [
{ id: 1, name: 'B1_Окна СПб' },
{ id: 2, name: 'B2_Двери' },
],
},
});
const r = await listProjects(1);
expect(apiClient.get).toHaveBeenCalledWith('/api/projects', { params: { tenant_id: 1 } });
+11 -1
View File
@@ -13,7 +13,17 @@ function makeStableMenu(left: number): HTMLElement {
const content = document.createElement('div');
content.className = 'v-overlay__content';
content.getBoundingClientRect = () =>
({ width: 400, height: 300, left, top: 50, right: left + 400, bottom: 350, x: left, y: 50, toJSON() {} }) as DOMRect;
({
width: 400,
height: 300,
left,
top: 50,
right: left + 400,
bottom: 350,
x: left,
y: 50,
toJSON() {},
}) as DOMRect;
overlay.appendChild(content);
document.body.appendChild(overlay);
return overlay;