149 lines
6.4 KiB
TypeScript
Executable File
149 lines
6.4 KiB
TypeScript
Executable File
/**
|
||
* Справочник пунктов и подпунктов осмотра аудита.
|
||
* Пункты: Кровля, Фасад, Подъезды, Инфраструктура, Придомовой участок.
|
||
*/
|
||
|
||
import type { InspectionCategoryKey } from '../../types';
|
||
|
||
export interface InspectionSubItemSchema {
|
||
key: string;
|
||
label: string;
|
||
}
|
||
|
||
export interface InspectionCategorySchema {
|
||
key: InspectionCategoryKey;
|
||
label: string;
|
||
subItems: InspectionSubItemSchema[];
|
||
}
|
||
|
||
export const AUDIT_INSPECTION_SCHEMA: InspectionCategorySchema[] = [
|
||
{
|
||
key: 'roof',
|
||
label: 'Кровля',
|
||
subItems: [
|
||
{ key: 'covering', label: 'Покрытие кровли' },
|
||
{ key: 'waterproofing', label: 'Гидроизоляция' },
|
||
{ key: 'drainage', label: 'Водосточная система' },
|
||
{ key: 'fencing', label: 'Ограждение кровли' },
|
||
{ key: 'hatches', label: 'Люки и выходы' },
|
||
],
|
||
},
|
||
{
|
||
key: 'facade',
|
||
label: 'Фасад',
|
||
subItems: [
|
||
{ key: 'walls', label: 'Состояние стен' },
|
||
{ key: 'joints', label: 'Межпанельные швы' },
|
||
{ key: 'windows', label: 'Оконные блоки' },
|
||
{ key: 'balconies', label: 'Балконы и лоджии' },
|
||
{ key: 'basement_floor', label: 'Цокольный этаж' },
|
||
],
|
||
},
|
||
{
|
||
key: 'entrances',
|
||
label: 'Подъезды',
|
||
subItems: [
|
||
{ key: 'doors', label: 'Входные двери' },
|
||
{ key: 'stairs', label: 'Лестничные марши' },
|
||
{ key: 'railings', label: 'Перила и ограждения' },
|
||
{ key: 'lighting', label: 'Освещение' },
|
||
{ key: 'walls_ceilings', label: 'Стены и потолки' },
|
||
{ key: 'elevator', label: 'Лифт (при наличии)' },
|
||
],
|
||
},
|
||
{
|
||
key: 'infrastructure',
|
||
label: 'Инфраструктура',
|
||
subItems: [
|
||
{ key: 'heating', label: 'Система отопления' },
|
||
{ key: 'water', label: 'ХВС/ГВС' },
|
||
{ key: 'sewage', label: 'Канализация' },
|
||
{ key: 'electrical', label: 'Электрощитовые' },
|
||
{ key: 'basement', label: 'Подвал / ИТП' },
|
||
{ key: 'gas', label: 'Газовое оборудование (при наличии)' },
|
||
],
|
||
},
|
||
{
|
||
key: 'yard',
|
||
label: 'Придомовой участок',
|
||
subItems: [
|
||
{ key: 'paving', label: 'Благоустройство территории' },
|
||
{ key: 'lighting', label: 'Наружное освещение' },
|
||
{ key: 'parking', label: 'Парковка' },
|
||
{ key: 'playground', label: 'Детская площадка (при наличии)' },
|
||
{ key: 'waste', label: 'Площадка для мусора' },
|
||
],
|
||
},
|
||
];
|
||
|
||
/** Оценки 1–5 для подпунктов */
|
||
export const INSPECTION_SCORE_LABELS: Record<number, string> = {
|
||
1: '1 — очень плохо',
|
||
2: '2 — плохо',
|
||
3: '3 — удовлетворительно',
|
||
4: '4 — хорошо',
|
||
5: '5 — отлично',
|
||
};
|
||
|
||
export const AUDIT_STATUS_LABELS: Record<string, string> = {
|
||
new: 'Новый',
|
||
in_progress: 'В работе',
|
||
completed: 'Завершён',
|
||
};
|
||
|
||
/** Нормализация оценки для отображения: число 1–5 или старые good/fair/poor → 5/3/1. */
|
||
export function normalizeRatingToScore(rating: unknown): number | null {
|
||
if (rating != null && typeof rating === 'number' && rating >= 1 && rating <= 5) return rating;
|
||
if (rating === 'good') return 5;
|
||
if (rating === 'fair') return 3;
|
||
if (rating === 'poor') return 1;
|
||
return null;
|
||
}
|
||
|
||
/** Среднее по подпунктам категории: только оценки 1–5, без noAccess/notPresent. Округление до 1 знака. */
|
||
export function calcCategoryAverage(subItems: { rating: number | null; noAccess?: boolean; notPresent?: boolean }[]): number | null {
|
||
const valid = subItems.filter(s => {
|
||
const score = normalizeRatingToScore(s.rating);
|
||
return score != null && !s.noAccess && !s.notPresent;
|
||
});
|
||
if (valid.length === 0) return null;
|
||
const sum = valid.reduce((a, s) => a + (normalizeRatingToScore(s.rating) ?? 0), 0);
|
||
return Math.round((sum / valid.length) * 10) / 10;
|
||
}
|
||
|
||
/** Износ % из средних по пунктам: (5 − среднее) / 4 × 100. categoryAverages — числа 1–5 или null. */
|
||
export function calcWearPercentFromCategoryAverages(categoryAverages: (number | null)[]): number | null {
|
||
const valid = categoryAverages.filter((a): a is number => a != null && a >= 1 && a <= 5);
|
||
if (valid.length === 0) return null;
|
||
const avg = valid.reduce((s, a) => s + a, 0) / valid.length;
|
||
return Math.round(((5 - avg) / 4) * 100);
|
||
}
|
||
|
||
/** Индекс сложности (0–100) из средних по пунктам осмотра — та же формула, что и износ: (5 − среднее) / 4 × 100. */
|
||
export function calcComplexityIndexFromCategoryAverages(categoryAverages: (number | null)[]): number | null {
|
||
return calcWearPercentFromCategoryAverages(categoryAverages);
|
||
}
|
||
|
||
/** Прогнозный тариф по износу %, индексу сложности и марже %. Формула как на бэкенде. */
|
||
export function calculateTariffFromAudit(params: { wearPercent: number; complexityIndex: number; projectedMargin: number }): number {
|
||
const BASE_TARIFF = 28;
|
||
const wearPercent = Math.max(0, Math.min(100, Number(params.wearPercent) || 0));
|
||
const complexityIndex = Math.max(0, Math.min(100, Number(params.complexityIndex) ?? 50));
|
||
const marginPercent = Math.max(0, Math.min(50, Number(params.projectedMargin) || 15));
|
||
const wearFactor = 1 + (wearPercent / 100) * 0.5;
|
||
const complexityFactor = 1 + (complexityIndex / 100) * 0.2;
|
||
const marginFactor = 1 / (1 - marginPercent / 100);
|
||
const tariff = BASE_TARIFF * wearFactor * complexityFactor * marginFactor;
|
||
return Math.round(tariff * 100) / 100;
|
||
}
|
||
|
||
/** Текст итога по среднему 1–5 (для отображения) */
|
||
export function formatCategoryOverall(avg: number | null): string {
|
||
if (avg == null) return '—';
|
||
if (avg >= 4.5) return `${avg} — отлично`;
|
||
if (avg >= 3.5) return `${avg} — хорошо`;
|
||
if (avg >= 2.5) return `${avg} — удовлетворительно`;
|
||
if (avg >= 1.5) return `${avg} — плохо`;
|
||
return `${avg} — очень плохо`;
|
||
}
|