a159bf73cb
Номер абонента Мотива не доходил даже до каналов: словарь операторов о Мотиве не знал, и номер выбывал на первой заставе «кому мы вообще шлём». Что сделано: · словарь знает ОБА сырых написания — торговое «Мотив» от ДаДаты и юридическое ООО "ЕКАТЕРИНБУРГ-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.
930 lines
41 KiB
Vue
930 lines
41 KiB
Vue
<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>
|