Files
portal/app/resources/js/api/admin.ts
T
Дмитрий b7ad9e0a66 merge: телеграм-реклама второй смены сведена в рабочую ветку перед выкатом СМС
Замер боевого перед выкатом СМС-модуля показал мину: на liderra.ru сейчас
работает код ветки fix/tg-zagolovok-obyavleniya — 33 записи телеграм-рекламы,
которых не было ни в main, ни в рабочей ветке. Сверено слепками файлов:
config/client_tg.php и TelegramTariffService.php на бою совпадают с их версией
и расходятся с нашей. Экраны портала собираются одним куском, поэтому выкат
СМС в прежнем виде откатил бы их живую рекламу назад. Решение владельца —
сперва забрать их работу к себе.

Разрешено пять склеек, все — сохранением обеих сторон:
- Tariff.php: их пояснение про закупочную цену плюс наша строка для подсказчика
  типов;
- phpstan-baseline.neon: взята наша вычищенная версия. Их 260 строк заметания
  не возвращены — статанализ после сведения дал 0 без них, потому что чинили мы
  не baseline, а сам механизм, и он вылечил их новые тесты тоже;
- advertising-telegram-view.spec.ts: наш типизированный мок оставлен;
- CHANGELOG_schema.md: столкновения номеров НЕ было — у нас v9.33/v9.34, у них
  v9.64/v9.65. Обе пары сохранены, ничего не двигали; в файл дописано, что дыра
  v9.35–v9.63 это след их завышенного замера, а не потерянные записи;
- STATUS.md: служебный файл наблюдателя, взят свежий.

Что сведение вскрыло дополнительно:
- их сторож значков поймал НАШИ три имени с экранов СМС — mdi-file-sign,
  mdi-account-badge-outline, mdi-file-download-outline в карте Lucide
  отсутствовали и на бою рисовались бы вопросом в кружке. Добавлены по смыслу;
- проверка типов фронта: 1 ошибка стала 0. Образец имени отправителя в тесте
  админки не задавал два поля, и Partial подмешивал в них undefined.

Проверено после сведения: PHP 4614 тестов, 4610 зелёных, 4 пропущено,
0 падений; экраны 250 файлов, 2018 зелёных, 3 пропущено, 0 падений;
статанализ 0 через composer stan; проверка типов 0.

Унаследованное, не мной внесённое и не тронутое: линтер фронта показывает
1 замечание на неразрывный пробел в advertising-sms-view.spec.ts — знак там
нужен по смыслу проверки, было до сведения.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 01:16:23 +03:00

1004 lines
35 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;
}
export interface EraseSubjectResult {
message: string;
counts: { users: number; leads: number; deals: number; webhook_log: number };
}
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;
}