fa11c7b223
Закрывает gap из v1.66 — mock-форма имеет mrrRub, но API возвращал null. Теперь AdminTenantsView показывает реальную колонку MRR. Backend (AdminTenantsController::index): - Добавлено tariff_plans.price_monthly as tariff_price_monthly в select. - mrr_rub в response: price_monthly (string) если не-trial; иначе null. - Aggregate-формат как у /admin/billing — string чтобы decimal не терял точность при передаче через JSON. Pest +3 (AdminTenantsIndexTest): - mrr_rub='990.00' для активного тарифа не-trial. - mrr_rub=null для trial (даже если тариф есть). - mrr_rub=null если current_tariff_id отсутствует. Frontend: - ApiAdminTenant.mrr_rub: string | null в типе. - mapApiAdminTenant: parseFloat(api.mrr_rub) или null (вместо hardcoded null из v1.66). - AdminTenantsView: formatRub(item.mrrRub) для консистентности с другими ₽-полями. Vitest +2: - mrr_rub строка → number. - mrr_rub=null → mrrRub null. PHPStan baseline регенерирован. cspell-glossary +консистентности. Регресс: - Lint+type-check+format passed. - Vitest 313/313 за 18.83 сек (+2 от 311). - Vite build 947 ms. - Pint + PHPStan passed. - Pest 266/266 за 28.39 сек (+3 от 263, 1001 assertion). Реестр v1.70→v1.71 / CLAUDE.md v1.61→v1.62. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
77 lines
3.4 KiB
TypeScript
77 lines
3.4 KiB
TypeScript
/**
|
|
* Маппер `AdminTenant` (api/admin.ts) → `AdminTenant` (mockTenants.ts UI-формат).
|
|
*
|
|
* Backend-схема (`tenants`) и UI-форма расходятся:
|
|
* - status — schema: active/suspended/pending_email_confirm/deleted;
|
|
* UI-форма ожидает: active/trial/overdue/suspended.
|
|
* → derived: is_trial=true → 'trial'; balance<0 || chargeback>0 → 'overdue';
|
|
* schema 'active'/'suspended' → as-is; иначе → 'suspended'.
|
|
* - inn — отсутствует в `tenants` (живёт в `legal_entities` для оператора SaaS
|
|
* и в `invoices.payer_inn` для покупателей). На MVP отдаём пустую строку.
|
|
* - todayActual — нет в API (требует JOIN на deals + GROUP BY DATE(received_at)).
|
|
* На MVP 0; добавим отдельным endpoint'ом если понадобится.
|
|
* - mrrRub — приходит из API как `tariff.price_monthly` (string, если
|
|
* не-trial); парсим в number. null для trial и tenant'ов без активного
|
|
* тарифа.
|
|
* - code — берём subdomain как «slug»-эквивалент.
|
|
*/
|
|
import type { AdminTenant as ApiAdminTenant } from '../api/admin';
|
|
import type { AdminTenant, TenantStatus, TenantTariff } from './mockTenants';
|
|
|
|
const TARIFF_NAME_FALLBACK: TenantTariff = 'Trial';
|
|
|
|
const KNOWN_TARIFFS: TenantTariff[] = ['Trial', 'Start', 'Команда', 'Pro', 'Enterprise'];
|
|
|
|
function deriveStatus(api: ApiAdminTenant): TenantStatus {
|
|
const balance = parseFloat(api.balance_rub);
|
|
const chargeback = parseFloat(api.chargeback_unrecovered_rub);
|
|
if (api.is_trial) return 'trial';
|
|
if (api.status === 'suspended') return 'suspended';
|
|
if (chargeback > 0 || balance < 0) return 'overdue';
|
|
if (api.status === 'active') return 'active';
|
|
return 'suspended';
|
|
}
|
|
|
|
function statusText(status: TenantStatus): string {
|
|
const map: Record<TenantStatus, string> = {
|
|
active: 'Активен',
|
|
trial: 'Trial',
|
|
overdue: 'Просрочка',
|
|
suspended: 'Приостановлен',
|
|
};
|
|
return map[status];
|
|
}
|
|
|
|
function deriveTariff(name: string | null): TenantTariff {
|
|
if (name === null) return TARIFF_NAME_FALLBACK;
|
|
const match = KNOWN_TARIFFS.find((t) => t === name);
|
|
return match ?? TARIFF_NAME_FALLBACK;
|
|
}
|
|
|
|
function formatRelative(iso: string | null, now: Date = new Date()): string {
|
|
if (iso === null) return '—';
|
|
const dt = new Date(iso);
|
|
const minutes = Math.max(0, Math.floor((now.getTime() - dt.getTime()) / 60000));
|
|
if (minutes < 60) return `${minutes} мин назад`;
|
|
if (minutes < 60 * 24) return `${Math.floor(minutes / 60)} ч назад`;
|
|
return `${Math.floor(minutes / (60 * 24))} д назад`;
|
|
}
|
|
|
|
export function mapApiAdminTenant(api: ApiAdminTenant, now: Date = new Date()): AdminTenant {
|
|
const status = deriveStatus(api);
|
|
return {
|
|
id: api.id,
|
|
code: api.subdomain,
|
|
name: api.organization_name,
|
|
inn: '', // нет в API
|
|
status,
|
|
statusText: statusText(status),
|
|
tariff: deriveTariff(api.tariff_name),
|
|
balanceRub: parseFloat(api.balance_rub),
|
|
todayDesired: api.desired_daily_numbers ?? 0,
|
|
todayActual: 0, // нет в API (потребует aggregate query)
|
|
mrrRub: api.mrr_rub !== null ? parseFloat(api.mrr_rub) : null,
|
|
activitySince: formatRelative(api.last_activity_at, now),
|
|
};
|
|
}
|