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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
+6
-5
@@ -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');
|
||||
|
||||
+6
-5
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"> лидов</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">
|
||||
|
||||
@@ -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, '');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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) ?? 'Страница'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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('Не удалось загрузить');
|
||||
|
||||
@@ -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('СМС');
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user