Files
portal/app/resources/js/api/admin.ts
T
Дмитрий 1f260c0b97 fix/обзвон: честный отчёт о стирании доходит до оператора - З-2.5, второй заход
Приёмка вскрыла, что первый заход воспроизвёл ту же беду на другом основании.

1. Невод по тексту не мог найти НИЧЕГО по построению.
   `obzvon_materialy_klienta.transcript` не пишет никто: расшифровщик живёт в
   З-4.2, волна 4. Значит сводка честно говорила "материалы клиента=0", пока
   голос человека лежал на диске 30 дней. Теперь портал считает отдельным числом
   записи, которые проверить НЕЧЕМ - звук жив, расшифровки нет и не было, - и
   говорит это тревожной строкой.
   🔴 Условие уточнено против предложенного: добавлено transcript_deleted_at IS
   NULL. Без него строка, у которой расшифровку стёрли мы сами, а файл убрать не
   смогли, попадала бы в оба числа сразу. Доказано надрезом.

2. Изоляция клиентов не сторожилась ничем. Соединение обходит защиту строк, значит
   отбор по клиенту в коде - ЕДИНСТВЕННЫЙ замок. Сняв его, приёмщик оставил все
   13 сторожей зелёными. Заведены сторожа на все три таблицы.

3. Своя находка того же класса: читатель флага сверял телефон точными написаниями
   с колонкой, которую заполняет ЧЕЛОВЕК руками. На записи "8 (900) 123-45-67"
   он молча отвечал "звонить можно" тому, кто потребовал прекратить обработку.
   Сверка переведена на хвост из десяти цифр - и там, и в переходнике.

4. Экран админки показывал зелёную галочку "выполнено" и пустое поле
   "Webhook-логов", а про обзвон и про нестёртые файлы молчал. Это видимая
   половина той же неправды: тот, кто жмёт кнопку, и есть тот, кто обязан пойти
   проверить руками. Экран называет обзвон поимённо, при нестёртых файлах и
   непроверяемых записях галочки нет вовсе - вместо неё тревога. Мёртвое поле
   убрано вместе с полем в типе ответа.

Сторожа: 13 -> 19 на сервере, плюс 5 экранных. Каждый показан красным семью
надрезами по коду.

Отчёт: docs/superpowers/priyomka/stroyka-2/z-2-5-otchyot-2026-08-05.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 23:26:49 +03:00

1030 lines
36 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;
}