Files
portal/app/resources/js/api/admin.ts
T
Дмитрий f83b845658 fix телеграм: экран разбора показывал смету вместо живых денег
Поймано на боевых данных 06.08.2026, ДО первого нажатия кнопки. В списке
застрявших оказались три кампании, а не одна — и у двух из них заморозку уже
отпустили, денег за ними не осталось.

Экран же показывал смету и подписывал её «Заморожено у клиента: 268,80 ₽».
Владелец нажал бы «вернуть деньги», не вернулось бы ничего, а он считал бы,
что вернул. Обещать возврат того, чего нет, — то же враньё, что зелёная
галочка над невыполненной работой.

Теперь в списке идёт живая заморозка, посчитанная одним запросом на весь
список. Когда её нет — так и написано, и кнопка меняет обещание на «просто
закрыть кампанию». Отдельно предупреждаем, когда списание пойдёт прямо с
баланса клиента, а не из отложенного: у кампании №10 на боевом ровно этот
случай — кабинету уплачено 180 ₽, а заморозки уже нет.

Проверено: модуль 355 тестов, экраны 16 тестов, статанализ 0, типы чисты.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 15:30:18 +03:00

1088 lines
39 KiB
TypeScript
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.
import { apiClient, ensureCsrfCookie } from './client';
/**
* API-вызовы для админских endpoint'ов SaaS (см. ImpersonationController).
*
* На MVP вызываются без auth:saas-admin middleware (см. routes/web.php).
* Production: middleware('auth:saas-admin') + cookie session — apiClient уже
* настроен на withCredentials.
*/
export interface ImpersonationInitPayload {
tenant_id: number;
requested_by: number; // на MVP параметром; на prod — request()->user()->id
reason: string; // ≥30 chars (валидируется на backend)
}
export interface ImpersonationInitResponse {
token_id: number;
expires_at: string; // ISO8601
sent_to_email: string;
/** dev-only: исчезнет после интеграции MailService на prod */
_dev_plain_code?: string;
}
export async function impersonationInit(payload: ImpersonationInitPayload): Promise<ImpersonationInitResponse> {
await ensureCsrfCookie();
const { data } = await apiClient.post<ImpersonationInitResponse>('/api/admin/impersonation/init', payload);
return data;
}
export interface ImpersonationVerifyPayload {
token_id: number;
code: string; // 6 цифр
}
export interface ImpersonationVerifyResponse {
token_id: number;
tenant_id: number;
used_at: string;
message: string;
machine_token: string;
expires_at: string;
}
export async function impersonationVerify(payload: ImpersonationVerifyPayload): Promise<ImpersonationVerifyResponse> {
await ensureCsrfCookie();
const { data } = await apiClient.post<ImpersonationVerifyResponse>('/api/admin/impersonation/verify', payload);
return data;
}
export interface ImpersonationEndResponse {
token_id: number;
session_ended_at: string;
message: string;
}
export async function impersonationEnd(tokenId: number): Promise<ImpersonationEndResponse> {
await ensureCsrfCookie();
const { data } = await apiClient.post<ImpersonationEndResponse>('/api/admin/impersonation/end', {
token_id: tokenId,
});
return data;
}
export interface ImpersonationActiveSession {
token_id: number;
tenant_id: number;
tenant_name: string | null;
requested_by: number;
reason: string;
sent_to_email: string;
used_at: string;
expires_at: string;
}
export interface ImpersonationRecentSession {
token_id: number;
tenant_id: number;
tenant_name: string | null;
requested_by: number;
reason: string;
used_at: string;
session_ended_at: string;
duration_seconds: number | null;
}
export async function impersonationActive(): Promise<ImpersonationActiveSession[]> {
const { data } = await apiClient.get<{ sessions: ImpersonationActiveSession[] }>('/api/admin/impersonation/active');
return data.sessions;
}
export async function impersonationRecent(): Promise<ImpersonationRecentSession[]> {
const { data } = await apiClient.get<{ sessions: ImpersonationRecentSession[] }>('/api/admin/impersonation/recent');
return data.sessions;
}
// === SaaS-admin → Тенанты: lookup для AdminTenantsView ===
export interface AdminTenant {
id: number;
subdomain: string;
organization_name: string;
contact_email: string;
status: string;
balance_rub: string;
balance_leads: number;
is_trial: boolean;
last_activity_at: string | null;
tariff_id: number | null;
tariff_name: string | null;
/** price_monthly активного тарифа если не-trial; иначе null. */
mrr_rub: string | null;
desired_daily_numbers: number | null;
chargeback_unrecovered_rub: string;
created_at: string | null;
}
interface AdminTenantsStats {
total: number;
active: number;
trial: number;
overdue: number;
}
export interface ListAdminTenantsParams {
status?: string;
/** Производные статусы UI (trial/overdue/active/suspended), csv — серверный multi-фильтр. */
statuses?: string;
/** Имена тарифов (tariff_plans.name), csv — серверный multi-фильтр. */
tariffs?: string;
search?: string;
limit?: number;
offset?: number;
}
export interface ListAdminTenantsResponse {
tenants: AdminTenant[];
total: number;
limit: number;
offset: number;
stats: AdminTenantsStats;
}
export async function listAdminTenants(params: ListAdminTenantsParams = {}): Promise<ListAdminTenantsResponse> {
const { data } = await apiClient.get<ListAdminTenantsResponse>('/api/admin/tenants', { params });
return data;
}
// === SaaS-admin → Тенанты → детали (для AdminTenantDetailView) ===
export interface ApiTenantUser {
id: number;
email: string;
first_name: string | null;
last_name: string | null;
is_active: boolean;
totp_enabled: boolean;
last_active_at: string | null;
last_login_at: string | null;
}
export interface ApiTenantProject {
id: number;
name: string;
tag: string | null;
is_active: boolean;
daily_limit_target: number;
suppliers_count: number;
leads_today: number;
}
export interface ApiTenantBalanceTx {
id: number;
type: string;
amount_rub: string;
amount_leads: number;
balance_rub_after: string | null;
description: string | null;
created_at: string;
}
export interface ApiTenantActivityEvent {
id: number;
event: string;
deal_id: number;
actor_email: string | null;
context: Record<string, unknown> | null;
created_at: string;
}
interface ApiTenantMetrics {
leads_today: number;
leads_this_week: number;
leads_this_month: number;
avg_lead_cost_rub: number | null;
runway_days: number | null;
}
export interface AdminTenantDetailResponse {
tenant: AdminTenant;
users: ApiTenantUser[];
projects: ApiTenantProject[];
balance_history: ApiTenantBalanceTx[];
activity: ApiTenantActivityEvent[];
metrics: ApiTenantMetrics;
}
export async function getAdminTenantDetail(subdomain: string): Promise<AdminTenantDetailResponse> {
const { data } = await apiClient.get<AdminTenantDetailResponse>(
`/api/admin/tenants/${encodeURIComponent(subdomain)}`,
);
return data;
}
// === SaaS-admin → Биллинг: aggregates пополнений/списаний ===
export interface ApiAdminBillingTenant {
id: number;
subdomain: string;
organization_name: string;
contact_email: string;
status: string;
balance_rub: string;
tariff_id: number | null;
tariff_name: string | null;
mrr_rub: string;
monthly_topups_rub: string;
monthly_charges_rub: string;
last_payment_at: string | null;
chargeback_unrecovered_rub: string;
}
interface ApiAdminBillingSummary {
total_mrr_rub: string;
monthly_revenue_rub: string;
overdue_count: number;
refunds_count_30d: number;
}
export interface ListAdminBillingResponse {
tenants: ApiAdminBillingTenant[];
summary: ApiAdminBillingSummary;
}
export async function listAdminBilling(search = ''): Promise<ListAdminBillingResponse> {
const { data } = await apiClient.get<ListAdminBillingResponse>('/api/admin/billing', {
params: { search },
});
return data;
}
// === SaaS-admin → Реклама: расход и маржа по тенантам ===
export type AdvertisingSpendPeriod = 'current_month' | 'last_month' | '90d' | 'all';
export interface ApiAdminAdvertisingSpendRow {
tenant_id: number;
tenant_name: string;
client_spend_rub: string;
yandex_cost_rub: string;
our_margin_rub: string;
}
export interface ApiAdminAdvertisingSpendTotals {
client_spend_rub: string;
yandex_cost_rub: string;
our_margin_rub: string;
}
export interface AdminAdvertisingSpendResponse {
data: ApiAdminAdvertisingSpendRow[];
totals: ApiAdminAdvertisingSpendTotals;
ad_margin_percent: string;
}
export async function fetchAdvertisingSpend(
period: AdvertisingSpendPeriod = 'current_month',
): Promise<AdminAdvertisingSpendResponse> {
const { data } = await apiClient.get<AdminAdvertisingSpendResponse>('/api/admin/advertising/spend', {
params: { period },
});
return data;
}
export interface AdminAdvertisingSettings {
client_cpm_rub: string;
ad_margin_percent: string;
}
export async function fetchAdSettings(): Promise<AdminAdvertisingSettings> {
const { data } = await apiClient.get<AdminAdvertisingSettings>('/api/admin/advertising/settings');
return data;
}
export async function updateAdSettings(
payload: Partial<AdminAdvertisingSettings>,
): Promise<AdminAdvertisingSettings> {
await ensureCsrfCookie();
const { data } = await apiClient.put<AdminAdvertisingSettings>('/api/admin/advertising/settings', payload);
return data;
}
// === SaaS-admin → Реклама: кампании, ждущие номер креатива Яндекса (8e) ===
export interface AdminAwaitingCampaign {
id: number;
tenant_id: number;
tenant_name: string;
name: string;
status: string;
yandex_creative_id: number | null;
landing_url: string | null;
estimated_impressions: number | null;
}
export async function fetchAdAwaitingCampaigns(): Promise<AdminAwaitingCampaign[]> {
const { data } = await apiClient.get<{ data: AdminAwaitingCampaign[] }>('/api/admin/advertising/campaigns');
return data.data ?? [];
}
// === SaaS-admin → Реклама: «ждёт разбора» — сбойные задания робота (задача 15) ===
/**
* Задание робота, которое закрылось сбоем. Это НЕ обычный отказ модерации — те клиент
* разбирает сам по причине в переписке. Это место, где цепочка встала: робот сходил
* в кабинет и не понял, что видит, либо не смог отвезти картинки.
*/
export interface AdminStuckRobotJob {
id: number;
campaign_id: number;
tenant_id: number;
tenant_name: string;
campaign_name: string;
campaign_status: string;
kind: string;
yandex_ad_id: number | null;
failure_reason: string | null;
attempts: number;
finished_at: string | null;
}
export async function fetchAdRobotStuck(): Promise<AdminStuckRobotJob[]> {
const { data } = await apiClient.get<{ data: AdminStuckRobotJob[] }>('/api/admin/advertising/robot-stuck');
return data.data ?? [];
}
export async function setAdCampaignCreative(
id: number,
yandexCreativeId: number,
): Promise<{ id: number; yandex_creative_id: number }> {
await ensureCsrfCookie();
const { data } = await apiClient.patch<{ id: number; yandex_creative_id: number }>(
`/api/admin/advertising/campaigns/${id}/creative`,
{ yandex_creative_id: yandexCreativeId },
);
return data;
}
// === SaaS-admin → Инциденты ===
export interface ApiAdminIncident {
id: number;
incident_id: string;
type: string;
severity: 'low' | 'medium' | 'high' | 'critical';
summary: string;
started_at: string;
detected_at: string;
resolved_at: string | null;
status: 'open' | 'investigating' | 'resolved';
affected_tenants_count: number;
affected_users_count: number | null;
rkn_notified: boolean;
rkn_notified_at: string | null;
rkn_deadline_at: string | null;
}
interface ApiAdminIncidentsSummary {
open: number;
investigating: number;
rkn_pending: number;
total_unresolved: number;
}
export interface ListAdminIncidentsParams {
type?: string;
severity?: string;
unresolved_only?: boolean;
limit?: number;
offset?: number;
}
export interface ListAdminIncidentsResponse {
incidents: ApiAdminIncident[];
total: number;
limit: number;
offset: number;
summary: ApiAdminIncidentsSummary;
}
export async function listAdminIncidents(params: ListAdminIncidentsParams = {}): Promise<ListAdminIncidentsResponse> {
const { data } = await apiClient.get<ListAdminIncidentsResponse>('/api/admin/incidents', { params });
return data;
}
// === SaaS-admin → Система: system_settings edit-flow ===
export interface SystemSetting {
key: string;
value: string;
type: 'int' | 'string' | 'decimal' | 'bool' | 'json';
description: string | null;
updated_at: string;
updated_by: number | null;
}
export async function listSystemSettings(): Promise<SystemSetting[]> {
const { data } = await apiClient.get<{ settings: SystemSetting[] }>('/api/admin/system-settings');
return data.settings;
}
export interface Yandex360BalanceStatus {
balance: number | null;
updated_at: string | null;
}
/** Ручной баланс Яндекс 360 (почта): текущее значение + когда обновляли. */
export async function getYandex360Balance(): Promise<Yandex360BalanceStatus> {
const { data } = await apiClient.get<Yandex360BalanceStatus>('/api/admin/settings/yandex360-balance');
return data;
}
/** Сохранить ручной баланс Яндекс 360 (null — очистить). Возвращает новый статус. */
export async function setYandex360Balance(balance: number | null): Promise<Yandex360BalanceStatus> {
await ensureCsrfCookie();
const { data } = await apiClient.put<Yandex360BalanceStatus>('/api/admin/settings/yandex360-balance', { balance });
return data;
}
export interface UpdateSystemSettingPayload {
value: string;
reason: string; // ≥30 chars
admin_user_id?: number; // опц.: id админа проставляет бэкенд из сессии (saas_admin auth); клиент не диктует
}
export interface UpdateSystemSettingResponse {
key: string;
value: string;
previous_value: string;
updated_at: string;
message: string;
}
export async function updateSystemSetting(
key: string,
payload: UpdateSystemSettingPayload,
): Promise<UpdateSystemSettingResponse> {
await ensureCsrfCookie();
const { data } = await apiClient.put<UpdateSystemSettingResponse>(
`/api/admin/system-settings/${encodeURIComponent(key)}`,
payload,
);
return data;
}
// === SaaS-admin → Биллинг: row-actions (Sprint 3D G4) ===
export interface AdminTariffPlan {
id: number;
name: string;
price_monthly: string;
}
export async function listAdminTariffPlans(): Promise<AdminTariffPlan[]> {
const { data } = await apiClient.get<{ plans: AdminTariffPlan[] }>('/api/admin/billing/tariff-plans');
return data.plans;
}
export async function updateTenantStatus(
id: number,
status: 'active' | 'suspended',
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,
});
return data;
}
export async function refundTenant(
id: number,
amountRub: number,
reason: string,
): Promise<{ id: number; balance_rub: string; transaction_id: number }> {
await ensureCsrfCookie();
const { data } = await apiClient.post<{ id: number; balance_rub: string; transaction_id: number }>(
`/api/admin/billing/tenants/${id}/refund`,
{ amount_rub: amountRub, reason },
);
return data;
}
export async function updateTenantBalance(
id: number,
payload: { balance_rub: string; reason?: string },
): Promise<{ id: number; balance_rub: string; delta: string; transaction_id: number }> {
await ensureCsrfCookie();
const { data } = await apiClient.patch<{
id: number;
balance_rub: string;
delta: string;
transaction_id: number;
}>(`/api/admin/tenants/${id}/balance`, payload);
return data;
}
export async function changeTenantTariff(
id: number,
tariffId: number,
reason: string,
): Promise<{ id: number; tariff_id: number; tariff_name: string }> {
await ensureCsrfCookie();
const { data } = await apiClient.patch<{ id: number; tariff_id: number; tariff_name: string }>(
`/api/admin/billing/tenants/${id}/tariff`,
{ tariff_id: tariffId, reason },
);
return data;
}
// === SaaS-admin → Инциденты: detail-view + РКН-notify (Sprint 3D G5/G6) ===
export interface ApiIncidentAffectedTenant {
id: number;
organization_name: string;
}
export interface ApiAdminIncidentDetail {
id: number;
incident_id: string;
type: string;
severity: 'low' | 'medium' | 'high' | 'critical';
summary: string;
root_cause: string | null;
postmortem_url: string | null;
started_at: string;
detected_at: string;
resolved_at: string | null;
status: 'open' | 'investigating' | 'resolved';
affected_tenants: ApiIncidentAffectedTenant[];
affected_users_count: number | null;
notification_sent_at: string | null;
rkn_notified: boolean;
rkn_notified_at: string | null;
rkn_deadline_at: string | null;
created_by_admin: string | null;
closed_by_admin: string | null;
created_at: string | null;
updated_at: string | null;
}
export async function getAdminIncidentDetail(id: number): Promise<ApiAdminIncidentDetail> {
const { data } = await apiClient.get<{ incident: ApiAdminIncidentDetail }>(`/api/admin/incidents/${id}`);
return data.incident;
}
export async function notifyIncidentRkn(id: number): Promise<ApiAdminIncidentDetail> {
await ensureCsrfCookie();
const { data } = await apiClient.post<{ incident: ApiAdminIncidentDetail }>(
`/api/admin/incidents/${id}/rkn-notify`,
{},
);
return data.incident;
}
// === SaaS-admin → Тарифная сетка (Plan 4 / Sprint 5C G3) ===
export interface AdminPricingTier {
tier_no: number;
leads_in_tier: number | null;
price_per_lead_kopecks: number;
effective_from: string;
}
export interface PricingTiersResponse {
active: AdminPricingTier[];
scheduled: Record<string, AdminPricingTier[]>;
}
export interface PricingTierEditorRow {
tier_no: number;
leads_in_tier: number | null;
price_rub: string;
}
export async function getPricingTiers(): Promise<PricingTiersResponse> {
const { data } = await apiClient.get<{ data: PricingTiersResponse }>('/api/admin/pricing-tiers');
return { active: data.data.active, scheduled: data.data.scheduled ?? {} };
}
export async function createPricingTiers(
tiers: PricingTierEditorRow[],
effectiveFrom?: string,
): Promise<{ effective_from: string }> {
await ensureCsrfCookie();
const payload: { tiers: PricingTierEditorRow[]; effective_from?: string } = { tiers };
if (effectiveFrom) payload.effective_from = effectiveFrom;
const { data } = await apiClient.post<{ effective_from: string }>('/api/admin/pricing-tiers', payload);
return data;
}
export async function deleteScheduledPricingTier(effectiveFrom: string): Promise<void> {
await ensureCsrfCookie();
await apiClient.delete(`/api/admin/pricing-tiers/scheduled/${effectiveFrom}`);
}
// === SaaS-admin → Цены поставщиков (Plan 4 / Sprint 5C G3) ===
export interface AdminSupplier {
id: number;
code: string;
name: string;
cost_rub: string;
quality_score: string;
is_active: boolean;
}
export async function getAdminSuppliers(): Promise<AdminSupplier[]> {
const { data } = await apiClient.get<{ data: AdminSupplier[] }>('/api/admin/suppliers');
return data.data;
}
export async function updateAdminSupplier(
id: number,
payload: { cost_rub: string; quality_score: string; is_active: boolean },
): Promise<AdminSupplier> {
await ensureCsrfCookie();
const { data } = await apiClient.patch<{ data: AdminSupplier }>(`/api/admin/suppliers/${id}`, payload);
return data.data;
}
// ---------------------------------------------------------------------------
// 152-ФЗ: обращения субъектов ПДн
// ---------------------------------------------------------------------------
export interface PdSubjectRequest {
id: number;
received_at: string;
subject_email: string | null;
subject_phone: string | null;
subject_full_name: string | null;
request_type: 'access' | 'rectification' | 'deletion' | 'objection';
description: string | null;
status: 'received' | 'in_progress' | 'completed' | 'rejected';
tenant_id: number | null;
assigned_admin_id: number | null;
response_text: string | null;
deadline_at: string;
completed_at: string | null;
processing_restricted: boolean;
}
export interface ListPdRequestsResponse {
data: PdSubjectRequest[];
total: number;
limit: number;
offset: number;
}
export interface CreatePdRequestPayload {
subject_email?: string;
subject_phone?: string;
subject_full_name?: string;
request_type: 'access' | 'rectification' | 'deletion' | 'objection';
description?: string;
tenant_id?: number | null;
}
/**
* Что обзвон убрал по требованию человека — З-2.5.
*
* `fayly_ostalis` и `materialy_ne_proverit` — НЕ обычные числа. Первое значит
* «файл жив, стереть не смогли», второе — «проверить было нечем: расшифровки у
* записи нет, а искать в чужих материалах можно только по её тексту». Оба
* обязаны быть видны оператору тревожно, а не строчкой в общем списке: зелёная
* галочка «выполнено» при живом голосе — то же враньё, что и отметка
* `completed` в базе.
*/
export interface ObzvonEraseCounts {
zvonki: number;
zvonki_zvuk: number;
zvonki_tekst: number;
zvonki_telefon: number;
spisok_nabora: number;
materialy: number;
materialy_zvuk: number;
materialy_tekst: number;
fayly_ostalis: number;
materialy_ne_proverit: number;
}
export interface EraseSubjectResult {
message: string;
// `webhook_log` здесь больше НЕТ: служба перестала его возвращать в мае,
// когда таблицу убрали миграцией 2026_05_24_140000_drop_legacy_webhook_artefacts.
// Поле оставалось в типе и в разметке и показывало оператору пустоту.
counts: { users: number; leads: number; deals: number; obzvon: ObzvonEraseCounts };
}
export async function listPdSubjectRequests(
params: { status?: string; request_type?: string; limit?: number; offset?: number } = {},
): Promise<ListPdRequestsResponse> {
const { data } = await apiClient.get<ListPdRequestsResponse>('/api/admin/pd-subject-requests', { params });
return data;
}
export async function createPdSubjectRequest(payload: CreatePdRequestPayload): Promise<PdSubjectRequest> {
await ensureCsrfCookie();
const { data } = await apiClient.post<{ data: PdSubjectRequest }>('/api/admin/pd-subject-requests', payload);
return data.data;
}
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);
return data;
}
// --- Оплата по счёту (Этап 1): список счетов + ручная отметка оплаты ---
export interface AdminInvoiceRow {
id: number;
invoice_number: string;
amount_total: string;
status: string;
issued_at: string;
expires_at: string | null;
tenant_id: number;
tenant_name: string | null;
payer_name: string | null;
}
export interface ListAdminInvoicesParams {
status?: string;
search?: string;
page?: number;
per_page?: number;
}
export interface ListAdminInvoicesResponse {
data: AdminInvoiceRow[];
meta: { current_page: number; last_page: number; total: number; per_page: number };
}
export async function listAdminInvoices(params: ListAdminInvoicesParams = {}): Promise<ListAdminInvoicesResponse> {
const { data } = await apiClient.get<ListAdminInvoicesResponse>('/api/admin/invoices', { params });
return data;
}
export async function markInvoicePaid(id: number): Promise<void> {
await ensureCsrfCookie();
await apiClient.post(`/api/admin/invoices/${id}/mark-paid`);
}
// === SaaS-admin → СМС: тарифы и имена (Task 16b) ===
/** Ступень тарифа СМС (строка GET /api/admin/sms/tariffs, а также PUT-ответ). */
export interface AdminSmsTariff {
id: number;
min_qty: number;
price_rub: string;
}
/** Строка редактируемой ступени в PUT /api/admin/sms/tariffs (без id — набор заменяется целиком). */
export interface AdminSmsTariffRow {
min_qty: number;
price_rub: string;
}
/**
* Общие настройки клиентских СМС: платное имя отправителя и границы окна отправки
* (часть GET /api/admin/sms/tariffs, а также PUT /api/admin/sms/settings).
*
* Часы окна — в местном времени ПОЛУЧАТЕЛЯ, общие для всех клиентов портала.
*/
export interface AdminSmsSettings {
name_fee_rub_per_operator: string;
name_debt_grace_days: number;
quiet_from_hour: number;
quiet_to_hour: number;
/** Через сколько минут без движения считать рассылку зависшей (строка листа 4.3). */
stuck_after_minutes: number;
/** Сколько номеров клиент может прислать за одну загрузку базы (строка листа 4.6). */
max_upload_phones: number;
/**
* Кому вообще шлём — операторы, чьих абонентов берём в рассылку (строка листа
* 4.14). Пусто = список не задан, действует четвёрка по умолчанию.
* 🔴 Не путать со списком операторов, с которыми согласовано имя отправителя:
* от того считается плата за имя (В-150).
*/
allowed_operators: string[] | null;
}
/** Ответ GET /api/admin/sms/tariffs. */
export interface AdminSmsTariffsResponse {
tariffs: AdminSmsTariff[];
settings: AdminSmsSettings;
}
/** Ответ PUT /api/admin/sms/tariffs — заменённый набор ступеней. */
export interface AdminSmsTariffsUpdateResponse {
tariffs: AdminSmsTariff[];
}
/** Заявка на имя отправителя (строка GET /api/admin/sms/senders, а также ответ approve/reject/disable). */
export interface AdminSmsSender {
id: number;
tenant_id: number;
name: string;
name_type: string;
status: string;
monthly_fee_rub: string;
note: string | null;
doc_original_name: string | null;
consent_doc_original_name: string | null;
}
/**
* Ссылка на скачивание приложенного клиентом скана. kind: 'basis' —
* документ-основание (право на имя), 'consent' — подписанное согласие.
*/
export function smsSenderDocumentUrl(id: number, kind: 'basis' | 'consent'): string {
return `/api/admin/sms/senders/${id}/document/${kind}`;
}
/** GET /api/admin/sms/tariffs — ступени тарифа + настройки платного имени. */
export async function fetchSmsTariffs(): Promise<AdminSmsTariffsResponse> {
const { data } = await apiClient.get<AdminSmsTariffsResponse>('/api/admin/sms/tariffs');
return data;
}
/** PUT /api/admin/sms/tariffs — заменить весь набор ступеней тарифа СМС. */
export async function updateSmsTariffs(rows: AdminSmsTariffRow[]): Promise<AdminSmsTariffsUpdateResponse> {
await ensureCsrfCookie();
const { data } = await apiClient.put<AdminSmsTariffsUpdateResponse>('/api/admin/sms/tariffs', { rows });
return data;
}
/**
* PUT /api/admin/sms/settings — плата за имя/мес, срок долга до отключения и границы
* окна отправки.
*
* Поля передаются объектом, а не по порядку: рядом стоят три числа (дни долга и два часа),
* и перепутанный порядок дал бы тихую беду — «долг 20 дней, окно с 29 часов».
*/
export async function updateSmsSettings(payload: {
name_fee_rub_per_operator: string;
name_debt_grace_days: number;
quiet_from_hour?: number;
quiet_to_hour?: number;
stuck_after_minutes?: number;
allowed_operators?: string[];
max_upload_phones?: number;
}): Promise<AdminSmsSettings> {
await ensureCsrfCookie();
const { data } = await apiClient.put<AdminSmsSettings>('/api/admin/sms/settings', payload);
return data;
}
/**
* Строка сверки нашего расчёта расхода перед оператором с расчётом самого
* оператора (строка листа 5.7). Видит ТОЛЬКО владелец (В-201).
*
* 🔴 Пустое и ноль — РАЗНЫЕ состояния. `null` значит «числа нет»: либо оператор
* цену не сообщил, либо у нас не задана цена канала. Показывать вместо этого
* «0 ₽» нельзя — экран рисовал бы идеальную сходимость там, где сверять нечем
* (В-234, В-235, В-236). Разница считается ТОЛЬКО когда известны ОБА числа.
*/
export interface AdminSmsReconcileRow {
campaign_id: number;
tenant_id: number;
title: string;
created_at: string;
messages: number;
/**
* По скольким из них оператор назвал цену. Отчёты приходят постепенно, поэтому
* у свежей рассылки это число меньше `messages` — и разница ниже посчитана
* только по ним (находка приёмки Этапа 5).
*/
messages_reported: number;
/** Наш счёт частей — есть всегда. */
our_parts: number;
/** Счёт частей у оператора — null, если его ещё не спрашивали. */
provider_parts: number | null;
/** Сколько сообщений, где числа частей разошлись. */
parts_mismatch: number;
our_cost: string | null;
/**
* Наш расход по ТЕМ ЖЕ сообщениям, по которым отчитался оператор. Именно он
* сравнивается с его счётом; null — сравнивать не с чем.
*/
our_cost_reported: string | null;
our_price_known: boolean;
provider_cost: string | null;
provider_cost_known: boolean;
/** Счёт оператора минус наш расход ПО ОТЧИТАННОМУ. */
difference: string | null;
}
/** GET /api/admin/sms/reconcile — сверка расчётов по рассылкам. */
export async function fetchSmsReconcile(): Promise<AdminSmsReconcileRow[]> {
const { data } = await apiClient.get<{ rows: AdminSmsReconcileRow[] }>('/api/admin/sms/reconcile');
return data.rows;
}
/** GET /api/admin/sms/senders?status=... — список заявок на имя отправителя. */
export async function fetchSmsSenders(status?: string): Promise<AdminSmsSender[]> {
const { data } = await apiClient.get<{ senders: AdminSmsSender[] }>('/api/admin/sms/senders', {
params: status ? { status } : {},
});
return data.senders;
}
/** POST /api/admin/sms/senders/{id}/approve — подтвердить имя отправителя. */
export async function approveSmsSender(id: number): Promise<AdminSmsSender> {
await ensureCsrfCookie();
const { data } = await apiClient.post<AdminSmsSender>(`/api/admin/sms/senders/${id}/approve`);
return data;
}
/** POST /api/admin/sms/senders/{id}/reject — отклонить имя отправителя (с опциональной причиной). */
export async function rejectSmsSender(id: number, note?: string): Promise<AdminSmsSender> {
await ensureCsrfCookie();
const { data } = await apiClient.post<AdminSmsSender>(`/api/admin/sms/senders/${id}/reject`, {
...(note !== undefined ? { note } : {}),
});
return data;
}
/** POST /api/admin/sms/senders/{id}/disable — отключить активное имя отправителя. */
export async function disableSmsSender(id: number): Promise<AdminSmsSender> {
await ensureCsrfCookie();
const { data } = await apiClient.post<AdminSmsSender>(`/api/admin/sms/senders/${id}/disable`);
return data;
}
/**
* Номер общего стоп-листа портала (строки листа 1.4, 1.5). Внесён один раз —
* закрыт у ВСЕХ клиентов сразу. Это защита договора с МТС при жалобе.
*/
export interface AdminSmsGlobalOptout {
phone: string;
reason: string | null;
created_at: string;
}
/** GET /api/admin/sms/global-optouts — номера, закрытые администрацией. */
export async function fetchSmsGlobalOptouts(): Promise<AdminSmsGlobalOptout[]> {
const { data } = await apiClient.get<{ items: AdminSmsGlobalOptout[]; total: number }>(
'/api/admin/sms/global-optouts',
);
return data.items;
}
/** POST /api/admin/sms/global-optouts — закрыть номер у всех клиентов сразу. */
export async function addSmsGlobalOptout(
phone: string,
reason: string | null,
): Promise<AdminSmsGlobalOptout> {
await ensureCsrfCookie();
const { data } = await apiClient.post<AdminSmsGlobalOptout>('/api/admin/sms/global-optouts', {
phone,
reason,
});
return data;
}
/** DELETE /api/admin/sms/global-optouts/{phone} — снова разрешить номер. */
export async function deleteSmsGlobalOptout(phone: string): Promise<void> {
await ensureCsrfCookie();
await apiClient.delete(`/api/admin/sms/global-optouts/${phone}`);
}
// --- Клиентская Telegram-реклама: тарифная сетка (задача 4.4) ---------------
// Зеркало SMS-функций выше. Ступень = ₽ за показ по объёму (у нас — потолок/оценка).
/** Вид медиа объявления — от него зависит цена показа у МТС. */
export type TgMediaKind = 'none' | 'image' | 'video';
/**
* Себестоимость показа Telegram-рекламы, ₽ за один показ (что платим МТС, с НДС).
* Одна строка на вид медиа: скидки за объём у поставщика нет (замер 02.08.2026).
*/
export interface AdminTgTariff {
id: number;
media_kind: TgMediaKind;
price_rub: string;
}
/** Строка в PUT /api/admin/telegram/tariffs — нужны ровно три, по одной на вид медиа. */
export interface AdminTgTariffRow {
media_kind: TgMediaKind;
price_rub: string;
}
/** Ответ GET /api/admin/telegram/tariffs. */
export interface AdminTgTariffsResponse {
tariffs: AdminTgTariff[];
}
/** Ответ PUT /api/admin/telegram/tariffs — заменённый набор ступеней. */
export interface AdminTgTariffsUpdateResponse {
tariffs: AdminTgTariff[];
}
/** GET /api/admin/telegram/tariffs — ступени тарифа. */
export async function fetchTgTariffs(): Promise<AdminTgTariffsResponse> {
const { data } = await apiClient.get<AdminTgTariffsResponse>('/api/admin/telegram/tariffs');
return data;
}
/** PUT /api/admin/telegram/tariffs — заменить весь набор ступеней. */
export async function updateTgTariffs(rows: AdminTgTariffRow[]): Promise<AdminTgTariffsUpdateResponse> {
await ensureCsrfCookie();
const { data } = await apiClient.put<AdminTgTariffsUpdateResponse>('/api/admin/telegram/tariffs', { rows });
return data;
}
/**
* Застрявшая телеграм-кампания (дефект Д-1, 06.08.2026).
*
* Робот довёл её до кабинета МТС и бросил на полпути. До появления разбора такая
* кампания застревала навсегда вместе с замороженными деньгами клиента.
* `mts_cost_rub` пуст — значит до кассы кабинета не дошли и списывать нечего.
*/
export interface AdminTgZastryavshaya {
id: number;
tenant_id: number;
status: string;
status_reason: string | null;
mts_campaign_id: string | null;
estimated_cost_rub: string | null;
mts_cost_rub: string | null;
/**
* 🔴 Сколько заперто ПРЯМО СЕЙЧАС, а не смета: заморозку могли уже отпустить.
* Необязательное — старый портал этого поля не отдавал, читаем как ноль.
*/
zamorozheno_rub?: string | null;
planned_count: number | null;
zastryala_s: string | null;
}
export interface AdminTgZastryavshieResponse {
campaigns: AdminTgZastryavshaya[];
}
/** Решение человека: рекламы не было (вернуть) либо реклама пошла (списать по факту). */
export type TgRazborReshenie = 'vernut' | 'spisat';
export interface AdminTgRazborResponse {
campaign: {
id: number;
status: string;
actual_cost_rub: string | null;
};
}
/** GET /api/admin/telegram/zastryavshie — кампании, ждущие ручного разбора. */
export async function fetchTgZastryavshie(): Promise<AdminTgZastryavshieResponse> {
const { data } = await apiClient.get<AdminTgZastryavshieResponse>('/api/admin/telegram/zastryavshie');
return data;
}
/** POST /api/admin/telegram/zastryavshie/{id}/razbor — исполнить решение по кампании. */
export async function razobratTgKampaniyu(
id: number,
reshenie: TgRazborReshenie,
): Promise<AdminTgRazborResponse> {
await ensureCsrfCookie();
const { data } = await apiClient.post<AdminTgRazborResponse>(
`/api/admin/telegram/zastryavshie/${id}/razbor`,
{ reshenie },
);
return data;
}