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 { await ensureCsrfCookie(); const { data } = await apiClient.post('/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 { await ensureCsrfCookie(); const { data } = await apiClient.post('/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 { await ensureCsrfCookie(); const { data } = await apiClient.post('/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 { const { data } = await apiClient.get<{ sessions: ImpersonationActiveSession[] }>('/api/admin/impersonation/active'); return data.sessions; } export async function impersonationRecent(): Promise { 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; /** Последний вход из журнала auth_log за 90 дней; null — входов не было. */ last_activity_at: string | null; /** Состояние по порогам 3/14 суток — для цветной точки в списке. */ aliveness_state: ApiAlivenessState; 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 { const { data } = await apiClient.get('/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 type ApiAlivenessState = 'active' | 'cooling' | 'lost' | 'never_logged_in' | 'no_data'; export interface ApiTenantAliveness { state: ApiAlivenessState; last_login_at: string | null; days_since_login: number | null; last_login_ip: string | null; last_login_user_agent: string | null; days: Array<{ date: string; visits: number }>; counters: { visits_30d: number; active_days_30d: number; actions_30d: number; failed_logins_30d: number; }; } export interface ApiTenantActivityItem { id: string; at: string; kind: 'project' | 'deal' | 'money'; event: string; actor: string | null; subject: string | null; amount_rub: string | null; bulk_count: number; } export interface ApiTenantActivityPage { items: ApiTenantActivityItem[]; offset: number; limit: number; has_more: boolean; } 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[]; aliveness: ApiTenantAliveness; activity: ApiTenantActivityPage; metrics: ApiTenantMetrics; } export async function getAdminTenantDetail(subdomain: string): Promise { const { data } = await apiClient.get( `/api/admin/tenants/${encodeURIComponent(subdomain)}`, ); return data; } export async function getAdminTenantActivity( subdomain: string, offset: number, limit = 50, ): Promise { const { data } = await apiClient.get( `/api/admin/tenants/${encodeURIComponent(subdomain)}/activity`, { params: { offset, limit } }, ); 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 { const { data } = await apiClient.get('/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 { const { data } = await apiClient.get('/api/admin/advertising/spend', { params: { period }, }); return data; } export interface AdminAdvertisingSettings { client_cpm_rub: string; ad_margin_percent: string; } export async function fetchAdSettings(): Promise { const { data } = await apiClient.get('/api/admin/advertising/settings'); return data; } export async function updateAdSettings( payload: Partial, ): Promise { await ensureCsrfCookie(); const { data } = await apiClient.put('/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 { 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 { 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 { const { data } = await apiClient.get('/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 { 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 { const { data } = await apiClient.get('/api/admin/settings/yandex360-balance'); return data; } /** Сохранить ручной баланс Яндекс 360 (null — очистить). Возвращает новый статус. */ export async function setYandex360Balance(balance: number | null): Promise { await ensureCsrfCookie(); const { data } = await apiClient.put('/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 { await ensureCsrfCookie(); const { data } = await apiClient.put( `/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 { 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 { const { data } = await apiClient.get<{ incident: ApiAdminIncidentDetail }>(`/api/admin/incidents/${id}`); return data.incident; } export async function notifyIncidentRkn(id: number): Promise { 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; } export interface PricingTierEditorRow { tier_no: number; leads_in_tier: number | null; price_rub: string; } export async function getPricingTiers(): Promise { 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 { 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 { 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 { 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 { const { data } = await apiClient.get('/api/admin/pd-subject-requests', { params }); return data; } export async function createPdSubjectRequest(payload: CreatePdRequestPayload): Promise { 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 { await ensureCsrfCookie(); const payload = adminUserId !== undefined ? { admin_user_id: adminUserId } : {}; const { data } = await apiClient.post(`/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 { const { data } = await apiClient.get('/api/admin/invoices', { params }); return data; } export async function markInvoicePaid(id: number): Promise { 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 { const { data } = await apiClient.get('/api/admin/sms/tariffs'); return data; } /** PUT /api/admin/sms/tariffs — заменить весь набор ступеней тарифа СМС. */ export async function updateSmsTariffs(rows: AdminSmsTariffRow[]): Promise { await ensureCsrfCookie(); const { data } = await apiClient.put('/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 { await ensureCsrfCookie(); const { data } = await apiClient.put('/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 { 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 { 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 { await ensureCsrfCookie(); const { data } = await apiClient.post(`/api/admin/sms/senders/${id}/approve`); return data; } /** POST /api/admin/sms/senders/{id}/reject — отклонить имя отправителя (с опциональной причиной). */ export async function rejectSmsSender(id: number, note?: string): Promise { await ensureCsrfCookie(); const { data } = await apiClient.post(`/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 { await ensureCsrfCookie(); const { data } = await apiClient.post(`/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 { 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 { await ensureCsrfCookie(); const { data } = await apiClient.post('/api/admin/sms/global-optouts', { phone, reason, }); return data; } /** DELETE /api/admin/sms/global-optouts/{phone} — снова разрешить номер. */ export async function deleteSmsGlobalOptout(phone: string): Promise { 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 { const { data } = await apiClient.get('/api/admin/telegram/tariffs'); return data; } /** PUT /api/admin/telegram/tariffs — заменить весь набор ступеней. */ export async function updateTgTariffs(rows: AdminTgTariffRow[]): Promise { await ensureCsrfCookie(); const { data } = await apiClient.put('/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 { const { data } = await apiClient.get('/api/admin/telegram/zastryavshie'); return data; } /** POST /api/admin/telegram/zastryavshie/{id}/razbor — исполнить решение по кампании. */ export async function razobratTgKampaniyu( id: number, reshenie: TgRazborReshenie, ): Promise { await ensureCsrfCookie(); const { data } = await apiClient.post( `/api/admin/telegram/zastryavshie/${id}/razbor`, { reshenie }, ); return data; }