Files
portal/app/resources/js/composables/adminTenantsMapper.ts
T
Дмитрий fa11c7b223 phase2(admin-tenants-mrr): mrr_rub в /api/admin/tenants (этап 7)
Закрывает 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>
2026-05-09 10:08:12 +03:00

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),
};
}