Files
portal/app/resources/js/views/admin/AdminSmsView.vue
T
Дмитрий a159bf73cb feat,смс: портал научился сети Мотива — маршрут через Билайна на выключателе
Номер абонента Мотива не доходил даже до каналов: словарь операторов о Мотиве
не знал, и номер выбывал на первой заставе «кому мы вообще шлём».

Что сделано:
· словарь знает ОБА сырых написания — торговое «Мотив» от ДаДаты и юридическое
  ООО "ЕКАТЕРИНБУРГ-2000" из реестра Россвязи, где слова «Мотив» нет ни разу
  замер по DEF-9xx.csv: 29 диапазонов, среди 89 имён реестра столкновений нет;
· Мотив добавлен к четвёрке владельца — сетей стало пять;
· маршрут через Билайна стоит на выключателе SMS_BEELINE_SERVES_MOTIV,
  умолчание ВЫКЛЮЧЕНО: до одобрения имени отправка вернёт 20230, а этот отказ
  окончательный — повтора нет, сообщение умрёт;
· цена по сети, а не одна на канал: 770 копеек за Мотива. Экран сверки тоже
  научен считать по сети — иначе занижал бы расход на 1,84 ₽ с сообщения;
· проверка ввода настроек и галки в админке пропускают Мотива: без этого
  владелец не смог бы его отметить, а заполненный список отменяет код;
· на клиентском экране «Имя отправителя» появилась пятая строка — скрыть её
  значило бы солгать умолчанием.

Тесты: модуль 548/548, было 530; 1801 проверка. Фронтенд 120/120, Sales 483/483.
Контрольные прогоны: убранное юридическое написание красит ровно четыре
зависящих от него теста, перевёрнутое умолчание выключателя — ровно один.

Не выкачено. Включать после того, как Билайн согласует имя — ориентир 24.08.2026.
2026-08-10 20:27:55 +03:00

930 lines
41 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* Админка SaaS → СМС: тарифы и имена (Task 16b).
*
* Три блока:
* 1. Тарифы (ступени) — редактируемая лестница цен ₽/СМС по объёму (min_qty).
* 2. Настройки имени и времени отправки — плата за платное имя отправителя (₽/мес за
* оператора), срок долга до автоматического отключения, границы окна отправки
* (часы местного времени получателя, строка листа 3.7) и срок, после которого
* рассылка считается зависшей (строка листа 4.3).
* 3. Имена на согласовании — заявки клиентов на своё имя отправителя (approve/reject/disable).
*
* Backend: AdminSmsController (см. routes/web.php § /api/admin/sms/*).
*/
import { onMounted, reactive, ref } from 'vue';
import * as adminApi from '../../api/admin';
import type { AdminSmsReconcileRow, AdminSmsSender, AdminSmsTariffRow } from '../../api/admin';
import { extractErrorMessage, extractValidationErrors } from '../../api/client';
const tariffRows = reactive<AdminSmsTariffRow[]>([]);
const tariffsLoading = ref(false);
const tariffsSaving = ref(false);
const tariffsError = ref('');
const nameFee = ref('');
const graceDays = ref(0);
/** Границы окна отправки — часы местного времени получателя, общие для всех клиентов (строка листа 3.7). */
const quietFromHour = ref(10);
const quietToHour = ref(20);
// Строка листа 4.3: через сколько минут без движения считать рассылку зависшей.
const stuckAfterMinutes = ref(60);
const maxUploadPhones = ref(50000);
// Строка листа 4.14 (решение владельца В-149): кому вообще шлём.
// 🔴 НЕ путать со списком операторов, с которыми согласовано имя отправителя:
// от того считается плата за имя, и связать их значило бы поднять её вчетверо.
const OPERATOR_CHOICES = [
{ key: 'mts', label: 'МТС' },
{ key: 'beeline', label: 'Билайн' },
{ key: 'megafon', label: 'Мегафон' },
{ key: 'tele2', label: 'Теле2' },
{ key: 'motiv', label: 'Мотив' },
{ key: 'yota', label: 'Йота' },
];
const allowedOperators = ref<string[]>(['mts', 'beeline', 'megafon', 'tele2', 'motiv']);
const settingsLoading = ref(false);
const settingsSaving = ref(false);
const settingsError = ref('');
const settingsSuccess = ref(false);
const senders = reactive<AdminSmsSender[]>([]);
const sendersLoading = ref(false);
const sendersError = ref('');
/** Человеческий вид типа имени. */
const NAME_TYPE_LABELS: Record<string, string> = {
legal: 'название юрлица',
ip: 'название ИП',
website: 'домен сайта',
trademark: 'товарный знак',
company: 'название компании',
};
function nameTypeLabel(t: string): string {
return NAME_TYPE_LABELS[t] ?? t;
}
/** Ссылка на скачивание приложенного скана: kind — basis/consent. */
function docUrl(id: number, kind: 'basis' | 'consent'): string {
return adminApi.smsSenderDocumentUrl(id, kind);
}
const rejectNoteBySenderId = reactive<Record<number, string>>({});
const rejectOpenSenderId = ref<number | null>(null);
const senderActionError = ref('');
async function loadTariffs() {
tariffsLoading.value = true;
tariffsError.value = '';
try {
const res = await adminApi.fetchSmsTariffs();
tariffRows.splice(0, tariffRows.length, ...res.tariffs.map((t) => ({ min_qty: t.min_qty, price_rub: t.price_rub })));
nameFee.value = res.settings.name_fee_rub_per_operator;
graceDays.value = res.settings.name_debt_grace_days;
quietFromHour.value = res.settings.quiet_from_hour;
quietToHour.value = res.settings.quiet_to_hour;
stuckAfterMinutes.value = res.settings.stuck_after_minutes;
maxUploadPhones.value = res.settings.max_upload_phones;
// Пусто в настройках = список не задан, действует четвёрка по умолчанию —
// ровно то же правило, что на сервере в AllowedSmsOperators::DEFAULT.
if (Array.isArray(res.settings.allowed_operators) && res.settings.allowed_operators.length > 0) {
allowedOperators.value = [...res.settings.allowed_operators];
}
} catch (e) {
tariffsError.value = extractErrorMessage(e, 'Не удалось загрузить тарифы СМС.');
} finally {
tariffsLoading.value = false;
}
}
async function loadSenders() {
sendersLoading.value = true;
sendersError.value = '';
try {
const res = await adminApi.fetchSmsSenders('pending');
senders.splice(0, senders.length, ...res);
} catch (e) {
sendersError.value = extractErrorMessage(e, 'Не удалось загрузить заявки на имя отправителя.');
} finally {
sendersLoading.value = false;
}
}
function addTariffRow() {
tariffRows.push({ min_qty: 1, price_rub: '0.00' });
}
function removeTariffRow(index: number) {
tariffRows.splice(index, 1);
}
async function saveTariffs() {
tariffsSaving.value = true;
tariffsError.value = '';
try {
const res = await adminApi.updateSmsTariffs(tariffRows.map((r) => ({ min_qty: r.min_qty, price_rub: r.price_rub })));
tariffRows.splice(0, tariffRows.length, ...res.tariffs.map((t) => ({ min_qty: t.min_qty, price_rub: t.price_rub })));
} catch (e) {
const validation = extractValidationErrors(e);
tariffsError.value =
validation && Object.keys(validation).length > 0
? Object.values(validation).flat().join(' ')
: extractErrorMessage(e, 'Не удалось сохранить тарифы.');
} finally {
tariffsSaving.value = false;
}
}
async function saveSettings() {
settingsSaving.value = true;
settingsError.value = '';
settingsSuccess.value = false;
try {
const res = await adminApi.updateSmsSettings({
name_fee_rub_per_operator: nameFee.value,
name_debt_grace_days: graceDays.value,
quiet_from_hour: quietFromHour.value,
quiet_to_hour: quietToHour.value,
stuck_after_minutes: stuckAfterMinutes.value,
max_upload_phones: maxUploadPhones.value,
allowed_operators: allowedOperators.value,
});
nameFee.value = res.name_fee_rub_per_operator;
graceDays.value = res.name_debt_grace_days;
quietFromHour.value = res.quiet_from_hour;
quietToHour.value = res.quiet_to_hour;
stuckAfterMinutes.value = res.stuck_after_minutes;
maxUploadPhones.value = res.max_upload_phones;
if (Array.isArray(res.allowed_operators) && res.allowed_operators.length > 0) {
allowedOperators.value = [...res.allowed_operators];
}
settingsSuccess.value = true;
} catch (e) {
const validation = extractValidationErrors(e);
settingsError.value =
validation && Object.keys(validation).length > 0
? Object.values(validation).flat().join(' ')
: extractErrorMessage(e, 'Не удалось сохранить настройки имени.');
} finally {
settingsSaving.value = false;
}
}
async function approve(id: number) {
senderActionError.value = '';
try {
await adminApi.approveSmsSender(id);
await loadSenders();
} catch (e) {
senderActionError.value = extractErrorMessage(e, 'Не удалось подтвердить имя отправителя.');
}
}
function openReject(id: number) {
rejectOpenSenderId.value = id;
if (rejectNoteBySenderId[id] === undefined) rejectNoteBySenderId[id] = '';
}
function cancelReject() {
rejectOpenSenderId.value = null;
}
async function confirmReject(id: number) {
senderActionError.value = '';
try {
const note = rejectNoteBySenderId[id]?.trim();
await adminApi.rejectSmsSender(id, note ? note : undefined);
rejectOpenSenderId.value = null;
await loadSenders();
} catch (e) {
senderActionError.value = extractErrorMessage(e, 'Не удалось отклонить имя отправителя.');
}
}
async function disable(id: number) {
senderActionError.value = '';
try {
await adminApi.disableSmsSender(id);
await loadSenders();
} catch (e) {
senderActionError.value = extractErrorMessage(e, 'Не удалось отключить имя отправителя.');
}
}
// ─── Общий стоп-лист портала (строки листа 1.4, 1.5) ────────────────────────
// Номер вносится один раз — и перестаёт получать СМС у ВСЕХ клиентов сразу.
// Нужен для защиты договора с МТС: пришла жалоба — закрыли номер по всему порталу.
const globalOptouts = reactive<adminApi.AdminSmsGlobalOptout[]>([]);
const optoutsLoading = ref(false);
const optoutsError = ref('');
const optoutPhone = ref('');
const optoutReason = ref('');
const optoutSaving = ref(false);
/**
* Сверка расчётов (строка листа 5.7) — ТОЛЬКО для владельца, клиенту не показывается (В-201).
*
* 🔴 Экран числа НЕ пересчитывает и пустое нулём НЕ подменяет: сервер присылает
* либо число, либо `null` с признаком «неизвестно», и здесь это ровно так и
* рисуется. Живая проба 31.07 шесть раз получила от МТС цену 0, потому что он
* ничего не отправил (В-235); нарисуй мы «0 ₽ против 0 ₽» — вышла бы идеальная
* сходимость на пустом месте.
*/
const reconcileRows = reactive<AdminSmsReconcileRow[]>([]);
const reconcileLoading = ref(false);
const reconcileError = ref('');
async function loadReconcile() {
reconcileLoading.value = true;
reconcileError.value = '';
try {
const items = (await adminApi.fetchSmsReconcile()) ?? [];
reconcileRows.splice(0, reconcileRows.length, ...items);
} catch (e) {
reconcileError.value = extractErrorMessage(e, 'Не удалось загрузить сверку расчётов.');
} finally {
reconcileLoading.value = false;
}
}
/** Деньги: число — рублями, пустое — честной причиной, а не нулём. */
function reconcileMoney(value: string | null, known: boolean, unknownText: string): string {
if (!known || value === null) {
return unknownText;
}
return `${Number(value).toFixed(2)}`;
}
async function loadGlobalOptouts() {
optoutsLoading.value = true;
optoutsError.value = '';
try {
const items = (await adminApi.fetchSmsGlobalOptouts()) ?? [];
globalOptouts.splice(0, globalOptouts.length, ...items);
} catch (e) {
optoutsError.value = extractErrorMessage(e, 'Не удалось загрузить общий стоп-лист.');
} finally {
optoutsLoading.value = false;
}
}
async function addGlobalOptout() {
const phone = optoutPhone.value.trim();
if (phone === '' || optoutSaving.value) return;
optoutSaving.value = true;
optoutsError.value = '';
try {
const reason = optoutReason.value.trim();
await adminApi.addSmsGlobalOptout(phone, reason === '' ? null : reason);
optoutPhone.value = '';
optoutReason.value = '';
await loadGlobalOptouts();
} catch (e) {
optoutsError.value = extractErrorMessage(e, 'Не удалось внести номер.');
} finally {
optoutSaving.value = false;
}
}
async function removeGlobalOptout(phone: string) {
optoutsError.value = '';
try {
await adminApi.deleteSmsGlobalOptout(phone);
await loadGlobalOptouts();
} catch (e) {
optoutsError.value = extractErrorMessage(e, 'Не удалось убрать номер.');
}
}
onMounted(() => {
loadTariffs();
loadSenders();
loadGlobalOptouts();
loadReconcile();
});
defineExpose({
tariffRows,
tariffsLoading,
tariffsSaving,
tariffsError,
nameFee,
graceDays,
quietFromHour,
quietToHour,
maxUploadPhones,
settingsLoading,
settingsSaving,
settingsError,
settingsSuccess,
senders,
sendersLoading,
sendersError,
senderActionError,
rejectOpenSenderId,
rejectNoteBySenderId,
loadTariffs,
loadSenders,
addTariffRow,
removeTariffRow,
saveTariffs,
saveSettings,
approve,
openReject,
cancelReject,
confirmReject,
disable,
nameTypeLabel,
docUrl,
// Общий стоп-лист портала.
globalOptouts,
optoutsLoading,
optoutsError,
optoutPhone,
optoutReason,
optoutSaving,
loadGlobalOptouts,
addGlobalOptout,
removeGlobalOptout,
});
</script>
<template>
<v-container fluid class="admin-sms pa-6" data-testid="admin-sms-view">
<header class="page-head mb-4">
<h1 class="text-h4 page-title">СМС: тарифы и имена</h1>
<p class="text-body-2 text-medium-emphasis ma-0">
Ступени цены за СМС, плата за платное имя отправителя и заявки клиентов на согласование.
</p>
</header>
<!-- Блок 1: Тарифы (ступени) -->
<v-card variant="outlined" class="pa-4 mb-6">
<h2 class="text-h6 mb-3">Тарифы (ступени)</h2>
<v-alert
v-if="tariffsError"
type="error"
variant="tonal"
density="compact"
closable
class="mb-3"
data-testid="sms-tariffs-error"
>
{{ tariffsError }}
</v-alert>
<div v-if="tariffsLoading" class="d-flex justify-center pa-6">
<v-progress-circular indeterminate color="primary" data-testid="sms-tariffs-loading" />
</div>
<div v-else>
<table class="tariff-table mb-3">
<thead>
<tr>
<th>От (шт.)</th>
<th>Цена, /СМС</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in tariffRows" :key="idx" :data-testid="`sms-tariff-row-${idx}`">
<td>
<v-text-field
v-model.number="row.min_qty"
type="number"
min="1"
density="compact"
hide-details
:data-testid="`sms-tariff-min-qty-${idx}`"
/>
</td>
<td>
<v-text-field
v-model="row.price_rub"
type="number"
step="0.01"
min="0"
density="compact"
hide-details
:data-testid="`sms-tariff-price-${idx}`"
/>
</td>
<td>
<v-btn
icon="mdi-delete-outline"
variant="text"
density="compact"
:data-testid="`sms-tariff-remove-${idx}`"
@click="removeTariffRow(idx)"
/>
</td>
</tr>
</tbody>
</table>
<div class="d-flex ga-2">
<v-btn
variant="outlined"
prepend-icon="mdi-plus"
data-testid="sms-tariffs-add-row"
@click="addTariffRow"
>
Добавить ступень
</v-btn>
<v-btn
color="primary"
:loading="tariffsSaving"
data-testid="sms-tariffs-save"
@click="saveTariffs"
>
Сохранить тарифы
</v-btn>
</div>
</div>
</v-card>
<!-- Блок 2: Настройки имени -->
<v-card variant="outlined" class="pa-4 mb-6">
<h2 class="text-h6 mb-3">Настройки имени и времени отправки</h2>
<v-alert
v-if="settingsError"
type="error"
variant="tonal"
density="compact"
closable
class="mb-3"
data-testid="sms-settings-error"
>
{{ settingsError }}
</v-alert>
<v-alert
v-if="settingsSuccess"
type="success"
variant="tonal"
density="compact"
closable
class="mb-3"
data-testid="sms-settings-success"
>
Настройки сохранены.
</v-alert>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
v-model="nameFee"
label="Плата за имя, ₽/мес за оператора"
type="number"
step="0.01"
min="0"
density="compact"
data-testid="sms-settings-name-fee"
/>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
v-model.number="graceDays"
label="Долг до отключения, дней"
type="number"
min="0"
density="compact"
data-testid="sms-settings-grace-days"
/>
</v-col>
</v-row>
<h3 class="text-subtitle-1 mt-2 mb-1">Когда отправляем СМС</h3>
<p class="text-body-2 text-medium-emphasis mb-3">
Часы по местному времени получателя: Камчатка и Калининград получат сообщение
каждый в своё утро. Клиент это время не настраивает оно общее для всех.
</p>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
v-model.number="quietFromHour"
label="Отправляем с, час"
type="number"
min="0"
max="23"
density="compact"
data-testid="sms-settings-quiet-from"
/>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
v-model.number="quietToHour"
label="по, час"
type="number"
min="1"
max="24"
density="compact"
data-testid="sms-settings-quiet-to"
/>
</v-col>
</v-row>
<h3 class="text-subtitle-1 mt-2 mb-1">Кому шлём</h3>
<p class="text-body-2 text-medium-emphasis mb-3">
Абонентам этих операторов рассылка уходит. Остальные номера в рассылку не берутся,
и клиент видит причину. Это не связано с тем, с какими операторами согласовано имя
отправителя, плата за имя отсюда не считается.
</p>
<div class="d-flex flex-wrap ga-4 mb-2" data-testid="sms-settings-allowed-operators">
<v-checkbox
v-for="op in OPERATOR_CHOICES"
:key="op.key"
v-model="allowedOperators"
:label="op.label"
:value="op.key"
:data-testid="`sms-settings-operator-${op.key}`"
density="compact"
hide-details
/>
</div>
<h3 class="text-subtitle-1 mt-2 mb-1">Когда считать рассылку зависшей</h3>
<p class="text-body-2 text-medium-emphasis mb-3">
Если рассылка идёт, но за это время не отправила ни одного сообщения, портал
сначала попробует её дожать, а потом остановит и вернёт клиенту замороженные
деньги. Рассылку, которая честно ждёт утра получателей, это не трогает.
</p>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
v-model.number="stuckAfterMinutes"
label="Считать зависшей через, минут"
type="number"
min="5"
max="1440"
density="compact"
data-testid="sms-settings-stuck-minutes"
/>
</v-col>
</v-row>
<h3 class="text-subtitle-1 mt-2 mb-1">Сколько номеров клиент грузит за раз</h3>
<p class="text-body-2 text-medium-emphasis mb-3">
Потолок на одну загрузку своей базы и списком, и файлом Excel. Сверх потолка
загрузка не принимается целиком, и клиент видит, сколько можно и сколько он
прислал. Оператора и часовой пояс по каждому новому номеру портал спрашивает у
ДаДаты за свои деньги, поэтому очень большие загрузки лучше резать на части.
</p>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
v-model.number="maxUploadPhones"
label="Не больше номеров за раз"
type="number"
min="1000"
max="100000"
density="compact"
data-testid="sms-settings-max-upload"
/>
</v-col>
</v-row>
<v-btn color="primary" :loading="settingsSaving" data-testid="sms-settings-save" @click="saveSettings">
Сохранить настройки
</v-btn>
</v-card>
<!-- Блок 3: Имена на согласовании -->
<v-card variant="outlined" class="pa-4">
<h2 class="text-h6 mb-3">Имена на согласовании</h2>
<v-alert
v-if="sendersError"
type="error"
variant="tonal"
density="compact"
closable
class="mb-3"
data-testid="sms-senders-error"
>
{{ sendersError }}
</v-alert>
<v-alert
v-if="senderActionError"
type="error"
variant="tonal"
density="compact"
closable
class="mb-3"
data-testid="sms-sender-action-error"
>
{{ senderActionError }}
</v-alert>
<div v-if="sendersLoading" class="d-flex justify-center pa-6">
<v-progress-circular indeterminate color="primary" data-testid="sms-senders-loading" />
</div>
<p
v-else-if="senders.length === 0"
class="text-body-2 text-medium-emphasis ma-0 pa-4"
data-testid="sms-senders-empty"
>
Заявок на согласовании нет.
</p>
<v-table v-else density="comfortable">
<thead>
<tr>
<th>Тенант</th>
<th>Имя</th>
<th>Тип</th>
<th class="text-right">/мес</th>
<th></th>
</tr>
</thead>
<tbody>
<template v-for="sender in senders" :key="sender.id">
<tr :data-testid="`sms-sender-row-${sender.id}`">
<td>{{ sender.tenant_id }}</td>
<td class="font-weight-medium">{{ sender.name }}</td>
<td>{{ nameTypeLabel(sender.name_type) }}</td>
<td class="text-right">{{ sender.monthly_fee_rub }}</td>
<td>
<div class="d-flex ga-2 justify-end flex-wrap">
<v-btn
v-if="sender.consent_doc_original_name"
size="small"
color="primary"
variant="text"
prepend-icon="mdi-file-sign"
:href="docUrl(sender.id, 'consent')"
target="_blank"
:data-testid="`sms-sender-consent-${sender.id}`"
>
Разрешение
</v-btn>
<v-btn
v-if="sender.doc_original_name"
size="small"
color="primary"
variant="text"
prepend-icon="mdi-paperclip"
:href="docUrl(sender.id, 'basis')"
target="_blank"
:data-testid="`sms-sender-doc-${sender.id}`"
>
Документ-основание
</v-btn>
<span
v-if="!sender.doc_original_name && !sender.consent_doc_original_name"
class="text-medium-emphasis text-caption align-self-center"
>нет документов</span>
<v-btn
size="small"
color="success"
variant="tonal"
:data-testid="`sms-sender-approve-${sender.id}`"
@click="approve(sender.id)"
>
Подтвердить
</v-btn>
<v-btn
size="small"
color="warning"
variant="tonal"
:data-testid="`sms-sender-reject-${sender.id}`"
@click="openReject(sender.id)"
>
Отклонить
</v-btn>
<v-btn
size="small"
color="error"
variant="tonal"
:data-testid="`sms-sender-disable-${sender.id}`"
@click="disable(sender.id)"
>
Отключить
</v-btn>
</div>
</td>
</tr>
<tr v-if="rejectOpenSenderId === sender.id" :data-testid="`sms-sender-reject-form-${sender.id}`">
<td colspan="5">
<div class="d-flex ga-2 align-center pa-2">
<v-text-field
v-model="rejectNoteBySenderId[sender.id]"
label="Причина отказа (необязательно)"
density="compact"
hide-details
:data-testid="`sms-sender-reject-note-${sender.id}`"
/>
<v-btn
color="warning"
size="small"
:data-testid="`sms-sender-reject-confirm-${sender.id}`"
@click="confirmReject(sender.id)"
>
Отклонить
</v-btn>
<v-btn size="small" variant="text" @click="cancelReject">Отмена</v-btn>
</div>
</td>
</tr>
</template>
</tbody>
</v-table>
</v-card>
<!-- Блок 4: Общий стоп-лист портала (строки листа 1.4, 1.5) -->
<v-card variant="outlined" class="pa-4 mb-6" data-testid="global-optouts-card">
<h2 class="text-h6 mb-1">Общий стоп-лист</h2>
<p class="text-body-2 text-medium-emphasis mb-3">
Номер отсюда не получит СМС ни от одного клиента портала. Сюда вносим номера по
жалобам клиент увидит причину «номер закрыт администрацией».
</p>
<v-alert v-if="optoutsError" type="error" density="compact" class="mb-3">
{{ optoutsError }}
</v-alert>
<div class="d-flex align-start ga-3 mb-3 flex-wrap">
<v-text-field
v-model="optoutPhone"
label="Номер"
density="comfortable"
variant="outlined"
hide-details
style="min-width: 220px"
data-testid="global-optout-phone"
/>
<v-text-field
v-model="optoutReason"
label="Зачем закрываем (видно только нам)"
density="comfortable"
variant="outlined"
hide-details
style="min-width: 320px"
data-testid="global-optout-reason"
/>
<v-btn
color="primary"
:loading="optoutSaving"
data-testid="global-optout-add"
@click="addGlobalOptout"
>
Закрыть номер
</v-btn>
</div>
<v-progress-linear v-if="optoutsLoading" indeterminate color="primary" class="mb-3" />
<v-table density="compact" data-testid="global-optouts-table">
<thead>
<tr>
<th>Номер</th>
<th>Зачем</th>
<th class="text-right"></th>
</tr>
</thead>
<tbody>
<tr
v-for="row in globalOptouts"
:key="row.phone"
:data-testid="`global-optout-row-${row.phone}`"
>
<td>{{ row.phone }}</td>
<td>{{ row.reason ?? '—' }}</td>
<td class="text-right">
<v-btn
icon="mdi-delete-outline"
variant="text"
size="small"
color="error"
:data-testid="`global-optout-delete-${row.phone}`"
@click="removeGlobalOptout(row.phone)"
/>
</td>
</tr>
<tr v-if="!optoutsLoading && globalOptouts.length === 0">
<td colspan="3" class="text-center text-medium-emphasis pa-6">
Закрытых номеров нет
</td>
</tr>
</tbody>
</v-table>
</v-card>
<!--
Строка листа 5.7: сверка НАШЕГО расчёта расхода перед оператором с тем,
что насчитал сам оператор. Видит только владелец (В-201) клиенту этот
экран недоступен вовсе.
-->
<v-card class="pa-5 mt-6" elevation="1" data-testid="sms-reconcile-card">
<h2 class="text-h6 mb-1">Сверка расчётов с оператором</h2>
<p class="text-body-2 text-medium-emphasis mb-4">
Сколько мы должны оператору по своему счёту и сколько насчитал он сам.
Расхождение здесь повод разобраться с оператором, а не подгонять наши числа.
</p>
<v-alert
v-if="reconcileError"
type="error"
variant="tonal"
class="mb-3"
data-testid="sms-reconcile-error"
>
{{ reconcileError }}
</v-alert>
<v-progress-linear v-if="reconcileLoading" indeterminate color="primary" class="mb-3" />
<v-table density="compact" data-testid="sms-reconcile-table">
<thead>
<tr>
<th>Рассылка</th>
<th>Клиент</th>
<th class="text-right">Сообщений</th>
<th class="text-right">Частей у нас</th>
<th class="text-right">Частей у оператора</th>
<th class="text-right">Расхождений</th>
<th class="text-right">Наш расход</th>
<th class="text-right">Счёт оператора</th>
<th class="text-right">Разница</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in reconcileRows"
:key="row.campaign_id"
:data-testid="`sms-reconcile-row-${row.campaign_id}`"
>
<td>{{ row.title }}</td>
<td>{{ row.tenant_id }}</td>
<td class="text-right">{{ row.messages }}</td>
<td class="text-right">{{ row.our_parts }}</td>
<!-- Пусто = оператора ещё не спрашивали, а не «ноль частей». -->
<td class="text-right">{{ row.provider_parts ?? 'не спрашивали' }}</td>
<td class="text-right">
<span
v-if="row.parts_mismatch > 0"
class="text-error font-weight-medium"
:data-testid="`sms-reconcile-mismatch-${row.campaign_id}`"
>
{{ row.parts_mismatch }}
</span>
<span v-else class="text-medium-emphasis">0</span>
</td>
<td class="text-right">
{{ reconcileMoney(row.our_cost, row.our_price_known, 'цена канала не задана') }}
</td>
<td class="text-right">
{{ reconcileMoney(row.provider_cost, row.provider_cost_known, 'оператор цену не сообщил') }}
</td>
<td class="text-right">
<div :data-testid="`sms-reconcile-diff-${row.campaign_id}`">
{{ row.difference === null ? 'сверять нечем' : `${Number(row.difference).toFixed(2)}` }}
</div>
<!-- Охват отчёта оператора. Без него «разница» вводит в
заблуждение: она посчитана только по отчитанному, а
«Наш расход» слева по всей рассылке. Пишем только
когда отчитались НЕ по всем, иначе строка шумела бы
всегда (находка приёмки Этапа 5). -->
<div
v-if="row.provider_cost_known && row.messages_reported < row.messages"
class="text-caption text-medium-emphasis"
:data-testid="`sms-reconcile-coverage-${row.campaign_id}`"
>
оператор отчитался по {{ row.messages_reported }} из {{ row.messages }}
</div>
</td>
</tr>
<tr v-if="!reconcileLoading && reconcileRows.length === 0">
<td colspan="9" class="text-center text-medium-emphasis pa-6" data-testid="sms-reconcile-empty">
Отправленных рассылок пока нет сверять нечего
</td>
</tr>
</tbody>
</v-table>
</v-card>
</v-container>
</template>
<style scoped>
.admin-sms {
max-width: 1400px;
}
.page-title {
font-variation-settings: 'opsz' 28;
letter-spacing: -0.018em;
}
.tariff-table {
width: 100%;
border-collapse: collapse;
}
.tariff-table th,
.tariff-table td {
padding: 4px 8px;
text-align: left;
}
</style>