Initial commit MKD fixes

This commit is contained in:
Arsen
2026-02-04 00:17:04 +05:00
commit de94ad707b
312 changed files with 138754 additions and 0 deletions

1455
services/apiClient.ts Executable file

File diff suppressed because it is too large Load Diff

206
services/connectionService.ts Executable file
View File

@@ -0,0 +1,206 @@
// Сервис управления подключением к серверу
export type ConnectionStatus = 'connected' | 'connecting' | 'disconnected';
type ConnectionStatusCallback = (status: ConnectionStatus) => void;
class ConnectionService {
private status: ConnectionStatus = 'disconnected';
private listeners: Set<ConnectionStatusCallback> = new Set();
private reconnectInterval: number | null = null;
private reconnectAttempts = 0;
private maxReconnectAttempts = Infinity; // Бесконечные попытки
private reconnectDelay = 3000; // 3 секунды между попытками
private checkInterval: number | null = null;
private apiBaseUrl: string;
constructor() {
this.apiBaseUrl = import.meta.env.VITE_API_BASE_URL || '';
// Если API не настроен, сразу устанавливаем статус connected
if (!this.apiBaseUrl) {
this.setStatus('connected');
return;
}
this.startConnectionCheck();
this.initializeConnection();
}
// Инициализация подключения
private async initializeConnection() {
if (!this.apiBaseUrl) {
// Если API не настроен, считаем что подключение не требуется (используется localStorage)
this.setStatus('connected');
return;
}
this.setStatus('connecting');
await this.checkConnection();
}
// Проверка подключения
private async checkConnection(): Promise<boolean> {
if (!this.apiBaseUrl) {
this.setStatus('connected');
return true;
}
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000); // 3 секунды таймаут
// Пробуем несколько endpoints для проверки подключения
const endpoints = ['/health', '/districts', '/buildings'];
let connected = false;
let lastError: Error | null = null;
for (const endpoint of endpoints) {
try {
const response = await fetch(`${this.apiBaseUrl}${endpoint}`, {
method: 'GET',
signal: controller.signal,
cache: 'no-cache',
headers: {
'Content-Type': 'application/json',
},
});
// Если получили ответ (даже с ошибкой 404, 403 и т.д.), сервер доступен
// 404 означает, что сервер работает, но endpoint не найден - это нормально
if (response.status < 500) {
connected = true;
break;
}
} catch (e) {
lastError = e instanceof Error ? e : new Error(String(e));
// Пробуем следующий endpoint
continue;
}
}
clearTimeout(timeoutId);
if (connected) {
this.setStatus('connected');
this.reconnectAttempts = 0;
this.stopReconnect();
return true;
} else {
// Проверяем, не была ли это ошибка сети (CORS, таймаут и т.д.)
const isNetworkError = lastError && (
lastError.name === 'AbortError' ||
lastError.message.includes('Failed to fetch') ||
lastError.message.includes('NetworkError') ||
lastError.message.includes('CORS')
);
if (isNetworkError) {
this.setStatus('disconnected');
this.startReconnect();
return false;
} else {
// Если это не ошибка сети, возможно сервер работает, но endpoint недоступен
// В этом случае считаем что подключение есть
this.setStatus('connected');
this.stopReconnect();
return true;
}
}
} catch (error) {
// Общая ошибка - считаем что нет подключения
this.setStatus('disconnected');
this.startReconnect();
return false;
}
}
// Установка статуса и уведомление слушателей
private setStatus(newStatus: ConnectionStatus) {
if (this.status !== newStatus) {
this.status = newStatus;
this.listeners.forEach(callback => callback(newStatus));
}
}
// Начало процесса переподключения
private startReconnect() {
if (this.reconnectInterval) {
return; // Уже переподключаемся
}
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
return; // Достигнут лимит попыток
}
this.reconnectInterval = window.setInterval(async () => {
this.reconnectAttempts++;
this.setStatus('connecting');
const connected = await this.checkConnection();
if (connected) {
this.stopReconnect();
}
}, this.reconnectDelay);
}
// Остановка процесса переподключения
private stopReconnect() {
if (this.reconnectInterval) {
clearInterval(this.reconnectInterval);
this.reconnectInterval = null;
}
this.reconnectAttempts = 0;
}
// Периодическая проверка подключения
private startConnectionCheck() {
// Проверяем каждые 10 секунд
this.checkInterval = window.setInterval(async () => {
if (this.status === 'connected') {
await this.checkConnection();
}
}, 10000);
}
// Получить текущий статус
getStatus(): ConnectionStatus {
return this.status;
}
// Подписаться на изменения статуса
subscribe(callback: ConnectionStatusCallback): () => void {
this.listeners.add(callback);
// Сразу вызываем callback с текущим статусом
callback(this.status);
// Возвращаем функцию отписки
return () => {
this.listeners.delete(callback);
};
}
// Принудительная проверка подключения
async forceCheck(): Promise<boolean> {
if (!this.apiBaseUrl) {
// Если API не настроен, считаем что подключение не требуется
this.setStatus('connected');
return true;
}
this.setStatus('connecting');
return await this.checkConnection();
}
// Очистка ресурсов
destroy() {
this.stopReconnect();
if (this.checkInterval) {
clearInterval(this.checkInterval);
this.checkInterval = null;
}
this.listeners.clear();
}
}
// Создаём единственный экземпляр сервиса
export const connectionService = new ConnectionService();

626
services/domaGraphQLClient.ts Executable file
View File

@@ -0,0 +1,626 @@
/**
* GraphQL клиент для работы с Doma AI API
* Документация: https://developers.doma.ai/ru/docs/api/about
*/
import { settingsService, DomaAISettings } from './settingsService';
import { getAuthToken } from './apiClient';
export interface DomaGraphQLResponse<T> {
data?: T;
errors?: Array<{
message: string;
extensions?: any;
}>;
}
export interface DomaAuthResponse {
authenticateUserWithPassword: {
token: string;
item: {
id: string;
name: string;
email?: string;
phone?: string;
};
};
}
export interface DomaTicket {
id: string;
number: string;
status: {
id: string;
name: string;
type: string;
};
details?: string;
description?: string;
createdAt: string;
updatedAt: string;
deadline?: string;
property?: {
id: string;
address: string;
unitName?: string;
};
client?: {
id: string;
name: string;
phone?: string;
};
assignee?: {
id: string;
name: string;
};
category?: {
id: string;
name: string;
};
}
export interface DomaTicketsResponse {
objs: DomaTicket[];
meta?: {
count: number;
};
}
class DomaGraphQLClient {
private apiUrl: string | null = null;
private token: string | null = null;
private tokenStorageKey = 'doma_ai_token';
constructor() {
// Восстанавливаем токен из localStorage, если есть
const storedToken = localStorage.getItem(this.tokenStorageKey);
if (storedToken) {
this.token = storedToken;
}
// Настраиваем URL бэкенда, через который проксируем запросы в Doma AI
this.updateApiUrl();
}
/**
* Обновляет URL API до нашего backend-прокси, а не напрямую до Doma AI
*/
private updateApiUrl(): void {
const backendBaseUrl = import.meta.env.VITE_API_BASE_URL;
if (backendBaseUrl) {
// Все запросы к Doma AI теперь идут через backend-прокси
this.apiUrl = `${backendBaseUrl.replace(/\/$/, '')}/doma/graphql`;
return;
}
this.apiUrl = null;
console.warn(
'[DomaGraphQLClient] VITE_API_BASE_URL не задан. ' +
'Настройте URL бэкенда в переменных окружения фронтенда.'
);
}
/**
* Устанавливает настройки API из настроек интеграций
*/
updateSettings(): void {
this.updateApiUrl();
}
/**
* Аутентификация пользователя через email и пароль
* Если параметры не переданы, пытается получить из настроек
*/
async authenticate(email?: string, password?: string): Promise<string> {
// Если параметры не переданы, пытаемся получить из настроек
if (!email || !password) {
const settings = settingsService.getDomaAISettings();
if (settings?.email && settings?.password) {
email = settings.email;
password = settings.password;
} else {
throw new Error('Учетные данные не указаны. Укажите email и пароль в настройках интеграций.');
}
}
// Обновляем URL перед авторизацией
this.updateApiUrl();
const mutation = `
mutation AuthenticateUser($email: String!, $password: String!) {
authenticateUserWithPassword(email: $email, password: $password) {
token
item {
id
name
email
phone
}
}
}
`;
const response = await this.request<DomaAuthResponse>(mutation, {
email,
password,
});
if (response.errors) {
throw new Error(response.errors[0]?.message || 'Ошибка аутентификации');
}
if (response.data?.authenticateUserWithPassword?.token) {
this.token = response.data.authenticateUserWithPassword.token;
localStorage.setItem(this.tokenStorageKey, this.token);
return this.token;
}
throw new Error('Не удалось получить токен авторизации');
}
/**
* Аутентификация через телефон и пароль
* Если параметры не переданы, пытается получить из настроек
*/
async authenticateWithPhone(phone?: string, password?: string): Promise<string> {
// Если параметры не переданы, пытаемся получить из настроек
if (!phone || !password) {
const settings = settingsService.getDomaAISettings();
if (settings?.phone && settings?.password) {
phone = settings.phone;
password = settings.password;
} else {
throw new Error('Учетные данные не указаны. Укажите телефон и пароль в настройках интеграций.');
}
}
// Обновляем URL перед авторизацией
this.updateApiUrl();
const mutation = `
mutation AuthenticateUserWithPhone($phone: String!, $password: String!) {
authenticateUserWithPhoneAndPassword(phone: $phone, password: $password) {
token
item {
id
name
email
phone
}
}
}
`;
interface PhoneAuthResponse {
authenticateUserWithPhoneAndPassword: {
token: string;
item: {
id: string;
name: string;
email?: string;
phone?: string;
};
};
}
const response = await this.request<PhoneAuthResponse>(mutation, {
phone,
password,
});
if (response.errors) {
throw new Error(response.errors[0]?.message || 'Ошибка аутентификации');
}
if (response.data?.authenticateUserWithPhoneAndPassword?.token) {
this.token = response.data.authenticateUserWithPhoneAndPassword.token;
localStorage.setItem(this.tokenStorageKey, this.token);
return this.token;
}
throw new Error('Не удалось получить токен авторизации');
}
/**
* Проверка текущего пользователя
*/
async getAuthenticatedUser() {
const query = `
query {
authenticatedUser {
id
name
email
phone
type
}
}
`;
const response = await this.request(query);
return response.data;
}
/**
* Получение списка заявок (tickets)
* В Doma AI заявки могут называться Ticket, ServiceRequest или аналогично
*/
async getTickets(filters?: {
status?: string;
assignee?: string;
property?: string;
limit?: number;
skip?: number;
}): Promise<DomaTicket[]> {
// Пробуем разные возможные названия типов заявок в Doma AI
// В зависимости от вашей схемы это может быть Ticket, ServiceRequest, Request и т.д.
const query = `
query GetTickets($where: TicketWhereInput, $first: Int, $skip: Int) {
objs: tickets(where: $where, first: $first, skip: $skip, orderBy: createdAt_DESC) {
id
number
status {
id
name
type
}
details
description
createdAt
updatedAt
deadline
property {
id
address
unitName
}
client {
id
name
phone
}
assignee {
id
name
}
category {
id
name
}
}
}
`;
const variables: any = {
first: filters?.limit || 100,
skip: filters?.skip || 0,
};
if (filters) {
variables.where = {};
if (filters.status) {
variables.where.status = { id: filters.status };
}
if (filters.assignee) {
variables.where.assignee = { id: filters.assignee };
}
if (filters.property) {
variables.where.property = { id: filters.property };
}
}
try {
const response = await this.request<{ objs: DomaTicket[] }>(query, variables);
if (response.errors) {
console.warn('[DomaGraphQLClient] Ошибка при получении заявок:', response.errors);
// Пробуем альтернативный запрос
return await this.getTicketsAlternative(filters);
}
return response.data?.objs || [];
} catch (error) {
console.error('[DomaGraphQLClient] Ошибка при запросе заявок:', error);
// Пробуем альтернативный запрос
return await this.getTicketsAlternative(filters);
}
}
/**
* Альтернативный запрос заявок (если основная схема не работает)
*/
private async getTicketsAlternative(filters?: any): Promise<DomaTicket[]> {
// Пробуем запрос через ServiceRequest или другие возможные типы
const alternativeQueries = [
// Вариант 1: ServiceRequest
`
query GetServiceRequests($where: ServiceRequestWhereInput, $first: Int, $skip: Int) {
objs: serviceRequests(where: $where, first: $first, skip: $skip, orderBy: createdAt_DESC) {
id
number
status {
id
name
type
}
details
description
createdAt
updatedAt
deadline
property {
id
address
unitName
}
client {
id
name
phone
}
assignee {
id
name
}
category {
id
name
}
}
}
`,
// Вариант 2: Request
`
query GetRequests($where: RequestWhereInput, $first: Int, $skip: Int) {
objs: requests(where: $where, first: $first, skip: $skip, orderBy: createdAt_DESC) {
id
number
status {
id
name
type
}
details
description
createdAt
updatedAt
deadline
property {
id
address
unitName
}
client {
id
name
phone
}
assignee {
id
name
}
category {
id
name
}
}
}
`,
];
for (const query of alternativeQueries) {
try {
const variables: any = {
first: filters?.limit || 100,
skip: filters?.skip || 0,
};
if (filters) {
variables.where = {};
if (filters.status) {
variables.where.status = { id: filters.status };
}
}
const response = await this.request<{ objs: DomaTicket[] }>(query, variables);
if (response.data?.objs) {
return response.data.objs;
}
} catch (error) {
console.warn('[DomaGraphQLClient] Альтернативный запрос не сработал:', error);
continue;
}
}
return [];
}
/**
* Базовый метод для выполнения GraphQL запросов
*/
private async request<T>(
query: string,
variables?: Record<string, any>
): Promise<DomaGraphQLResponse<T>> {
if (!this.apiUrl) {
throw new Error('VITE_DOMA_AI_API_URL не настроен. Укажите URL вашего инстанса Doma AI.');
}
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
const portalToken = getAuthToken();
if (portalToken) {
(headers as Record<string, string>)['Authorization'] = `Bearer ${portalToken}`;
}
// Важно: авторизационный токен к Doma AI больше не отправляем из браузера напрямую.
// Вместо этого передаём его в config.body на наш backend-прокси.
// Читаем актуальные настройки интеграции
const settings = settingsService.getDomaAISettings();
const config = {
apiUrl: settings?.apiUrl,
token: settings?.token || this.token || null,
};
try {
const bodyPayload = {
query,
variables: variables || {},
config,
};
console.log('[DomaGraphQLClient] Запрос к Doma AI', {
url: this.apiUrl,
hasToken: !!this.token,
// Лучше не логировать полный запрос, чтобы не засорять консоль,
// показываем только первые символы
queryPreview: query.slice(0, 100),
});
const response = await fetch(this.apiUrl, {
method: 'POST',
headers,
body: JSON.stringify(bodyPayload),
});
const rawText = await response.text();
console.log('[DomaGraphQLClient] Ответ Doma AI', {
status: response.status,
ok: response.ok,
length: rawText.length,
preview: rawText.slice(0, 300),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
if (!rawText) {
// Пустой ответ от Doma AI
return {} as DomaGraphQLResponse<T>;
}
const result: DomaGraphQLResponse<T> = JSON.parse(rawText);
// Если получили ошибку авторизации, очищаем токен
if (result.errors?.some(e => e.message.includes('auth') || e.message.includes('unauthorized'))) {
this.token = null;
localStorage.removeItem(this.tokenStorageKey);
throw new Error('Требуется повторная авторизация');
}
return result;
} catch (error) {
console.error('[DomaGraphQLClient] Ошибка запроса:', error);
throw error;
}
}
/**
* Установка токена вручную (если токен получен извне)
*/
setToken(token: string): void {
this.token = token;
localStorage.setItem(this.tokenStorageKey, token);
}
/**
* Очистка токена (выход)
*/
clearToken(): void {
this.token = null;
localStorage.removeItem(this.tokenStorageKey);
}
/**
* Проверка наличия токена
*/
hasToken(): boolean {
return !!this.token;
}
/**
* Обновление заявки (ticket) в Doma AI
* @param ticketId - ID заявки в Doma AI (UUID)
* @param data - Данные для обновления
*/
async updateTicket(
ticketId: string,
data: {
status?: { id?: string; type?: string };
statusReason?: string;
deferredUntil?: string;
lastCommentWithResidentTypeAt?: string;
}
): Promise<{ id: string; number: string }> {
const mutation = `
mutation UpdateTicket($id: ID!, $data: TicketUpdateInput!) {
obj: updateTicket(id: $id, data: $data) {
id
number
status {
id
name
type
}
updatedAt
}
}
`;
const variables: any = {
id: ticketId,
data: {},
};
// Маппинг локальных статусов в статусы Doma.AI
const statusTypeMap: Record<string, string> = {
new: 'new_or_reopened',
in_progress: 'processing',
deferred: 'deferred',
done: 'completed',
canceled: 'canceled',
};
if (data.status?.type) {
const domaStatusType = statusTypeMap[data.status.type] || data.status.type;
variables.data.status = { type: domaStatusType };
} else if (data.status?.id) {
variables.data.status = { id: data.status.id };
}
if (data.statusReason) {
variables.data.statusReason = data.statusReason;
}
if (data.deferredUntil) {
variables.data.deferredUntil = data.deferredUntil;
}
if (data.lastCommentWithResidentTypeAt) {
variables.data.lastCommentWithResidentTypeAt = data.lastCommentWithResidentTypeAt;
}
// Устанавливаем дату обновления статуса
variables.data.statusUpdatedAt = new Date().toISOString();
const response = await this.request<{ obj: { id: string; number: string } }>(mutation, variables);
if (response.errors) {
throw new Error(response.errors[0]?.message || 'Ошибка при обновлении заявки');
}
if (!response.data?.obj) {
throw new Error('Не удалось обновить заявку');
}
return response.data.obj;
}
}
export const domaGraphQLClient = new DomaGraphQLClient();

190
services/domaService.ts Executable file
View File

@@ -0,0 +1,190 @@
import { DomaApplication, DomaApplicationStatus } from '../types';
import { backendApi } from './apiClient';
import { domaGraphQLClient, DomaTicket } from './domaGraphQLClient';
import { settingsService } from './settingsService';
/**
* Сервис для работы с Doma AI API
* Интегрирует GraphQL API Doma AI для получения заявок
*/
export const domaService = {
/**
* Инициализация подключения к Doma AI
* Выполняет аутентификацию, если токен не сохранен
* Использует настройки из localStorage или переменные окружения как fallback
*/
async initialize(): Promise<boolean> {
// Обновляем настройки в клиенте
domaGraphQLClient.updateSettings();
// Получаем настройки из localStorage
const settings = settingsService.getDomaAISettings();
// Проверяем, настроен ли URL API (из настроек или переменных окружения)
const apiUrl = settings?.apiUrl || import.meta.env.VITE_DOMA_AI_API_URL;
if (!apiUrl) {
console.warn(
'[domaService] URL API Doma AI не настроен. ' +
'Укажите URL в настройках интеграций или в переменных окружения.'
);
return false;
}
// Если в настройках уже есть токен, просто используем его (без health-check запроса)
if (settings?.token) {
domaGraphQLClient.setToken(settings.token);
return true;
}
// Если токен уже есть в клиенте, считаем, что авторизация настроена
if (domaGraphQLClient.hasToken()) {
return true;
}
// Пытаемся получить учетные данные из настроек или переменных окружения
const email = settings?.email || import.meta.env.VITE_DOMA_AI_EMAIL;
const password = settings?.password || import.meta.env.VITE_DOMA_AI_PASSWORD;
const phone = settings?.phone || import.meta.env.VITE_DOMA_AI_PHONE;
if (!email && !phone) {
console.warn(
'[domaService] Учетные данные Doma AI не настроены. ' +
'Укажите email/телефон и пароль в настройках интеграций или в переменных окружения.'
);
return false;
}
if (!password) {
console.warn('[domaService] Пароль Doma AI не настроен. Укажите пароль в настройках интеграций.');
return false;
}
try {
if (email && password) {
await domaGraphQLClient.authenticate(email, password);
} else if (phone && password) {
await domaGraphQLClient.authenticateWithPhone(phone, password);
} else {
return false;
}
return true;
} catch (error) {
console.error('[domaService] Ошибка авторизации в Doma AI:', error);
return false;
}
},
/**
* Преобразует заявку из формата Doma AI в формат приложения
*/
mapDomaTicketToApplication(ticket: DomaTicket): DomaApplication {
// Маппинг статусов Doma AI в статусы приложения
const statusMap: Record<string, DomaApplicationStatus> = {
'new': 'new',
'in_progress': 'in_progress',
'inProgress': 'in_progress',
'done': 'done',
'completed': 'done',
'canceled': 'canceled',
'cancelled': 'canceled',
};
const statusType = ticket.status?.type?.toLowerCase() || ticket.status?.name?.toLowerCase() || 'new';
const mappedStatus = statusMap[statusType] || 'new';
return {
id: parseInt(ticket.id) || Date.now(), // Если ID не число, используем timestamp
number: ticket.number || ticket.id,
status: mappedStatus,
description: ticket.description || ticket.details || 'Без описания',
address: ticket.property?.address || 'Адрес не указан',
apartment: ticket.property?.unitName || '—',
clientName: ticket.client?.name || 'Клиент не указан',
createdAt: ticket.createdAt || new Date().toISOString(),
deadlineAt: ticket.deadline || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), // По умолчанию +7 дней
performer: ticket.assignee ? { name: ticket.assignee.name } : undefined,
};
},
/**
* Получает список заявок из Doma AI
* @param filters - Фильтры для запроса заявок
* @returns Массив заявок в формате приложения
*/
async getApplications(filters?: {
status?: DomaApplicationStatus;
limit?: number;
}): Promise<DomaApplication[]> {
console.log('[domaService] Получение заявок из Doma AI...');
// Проверяем и инициализируем подключение, если нужно
if (!domaGraphQLClient.hasToken()) {
const initialized = await this.initialize();
if (!initialized) {
console.warn('[domaService] Не удалось подключиться к Doma AI, используем fallback');
return this.getApplicationsFallback();
}
}
try {
// Преобразуем фильтры для Doma AI
const domaFilters: any = {};
if (filters?.status) {
// Маппинг статусов обратно (упрощенный вариант)
// В реальности нужно знать ID статусов в Doma AI
domaFilters.status = filters.status;
}
// Получаем заявки из Doma AI
const tickets = await domaGraphQLClient.getTickets({
...domaFilters,
limit: filters?.limit || 100,
});
// Преобразуем в формат приложения
const applications = tickets.map(ticket => this.mapDomaTicketToApplication(ticket));
console.log(`[domaService] Получено ${applications.length} заявок из Doma AI`);
return applications;
} catch (error) {
console.error('[domaService] Ошибка при получении заявок из Doma AI:', error);
// Fallback: пробуем получить из бэкенда
return this.getApplicationsFallback();
}
},
/**
* Fallback метод: получает заявки из бэкенда
* Важно: демо-мок-данные больше не используются, чтобы не путать с реальной интеграцией
*/
async getApplicationsFallback(): Promise<DomaApplication[]> {
try {
console.log('[domaService] Попытка получить заявки из бэкенда...');
const apps = await backendApi.getApplications();
return apps;
} catch (error) {
console.warn('[domaService] Бэкенд недоступен, возвращаем пустой список заявок:', error);
return [];
}
},
/**
* Обновление статуса заявки в Doma AI
* @param ticketId - ID заявки в Doma AI
* @param status - Новый статус
*/
async updateApplicationStatus(ticketId: string, status: DomaApplicationStatus): Promise<boolean> {
if (!domaGraphQLClient.hasToken()) {
const initialized = await this.initialize();
if (!initialized) {
throw new Error('Не удалось авторизоваться в Doma AI');
}
}
// TODO: Реализовать мутацию для обновления статуса заявки
// Это зависит от схемы GraphQL в Doma AI
console.warn('[domaService] Обновление статуса заявки в Doma AI пока не реализовано');
return false;
},
};

55
services/geminiService.ts Executable file
View File

@@ -0,0 +1,55 @@
// Заглушки для функций AI (Gemini отключен)
import { Building, ResidentFeedback, AIAnalysisResult, AnalyzedFeedback } from "../types";
export const generateBuildingAudit = async (building: Building): Promise<string> => {
// Заглушка - функция AI отключена
return `# Анализ дома ${building.passport.address}
## Общее состояние
Дом в эксплуатации. Требуется ручной анализ данных.
## Финансовое состояние
- Баланс: ${building.financials.balance.toLocaleString('ru-RU')}
- Собираемость: ${building.financials.collectionRate}%
- Задолженность: ${building.financials.debt.toLocaleString('ru-RU')}
*Примечание: Функция AI анализа отключена.*
`;
};
export const generateResidentFinancialSummary = async (building: Building): Promise<string> => {
// Заглушка - функция AI отключена
const balance = building.financials.balance;
const collectionRate = building.financials.collectionRate;
return `Уважаемые жители дома ${building.passport.address}!
💰 Баланс дома: ${balance.toLocaleString('ru-RU')}
📊 Собираемость платежей: ${collectionRate}%
${collectionRate > 90 ? '✅ Благодарим за своевременную оплату!' : '⚠️ Просим своевременно оплачивать коммунальные услуги.'}
*Примечание: Функция AI генерации отключена. Текст сформирован автоматически.*
`;
};
export const analyzeResidentFeedback = async (feedback: ResidentFeedback[]): Promise<AIAnalysisResult | null> => {
// Заглушка - функция AI отключена
// Возвращаем базовый анализ без AI
const analyzedFeedback: AnalyzedFeedback[] = feedback.map(f => ({
...f,
category: 'Другое',
sentiment: f.rating >= 7 ? 'Positive' : f.rating <= 3 ? 'Negative' : 'Neutral'
}));
const positiveCount = analyzedFeedback.filter(f => f.sentiment === 'Positive').length;
const negativeCount = analyzedFeedback.filter(f => f.sentiment === 'Negative').length;
return {
analyzedFeedback,
summary: {
positive: positiveCount > 0 ? [`Получено ${positiveCount} положительных отзывов`] : [],
negative: negativeCount > 0 ? [`Получено ${negativeCount} отрицательных отзывов`] : []
}
};
};

141
services/offlineCacheService.ts Executable file
View File

@@ -0,0 +1,141 @@
// Сервис кэширования данных для offline режима
type CachedRequest = {
id: string;
method: string;
url: string;
body?: unknown;
timestamp: number;
retries: number;
};
const CACHE_KEY = 'mkd_offline_cache';
const MAX_RETRIES = 10;
const MAX_CACHE_AGE = 7 * 24 * 60 * 60 * 1000; // 7 дней
class OfflineCacheService {
// Сохранить запрос в кэш
cacheRequest(method: string, url: string, body?: unknown): string {
const cachedRequests = this.getCachedRequests();
const id = `req-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const request: CachedRequest = {
id,
method,
url,
body,
timestamp: Date.now(),
retries: 0,
};
cachedRequests.push(request);
this.saveCachedRequests(cachedRequests);
return id;
}
// Получить все закэшированные запросы
getCachedRequests(): CachedRequest[] {
try {
const stored = localStorage.getItem(CACHE_KEY);
if (stored) {
const requests = JSON.parse(stored) as CachedRequest[];
// Фильтруем устаревшие запросы
const now = Date.now();
return requests.filter(req => now - req.timestamp < MAX_CACHE_AGE);
}
return [];
} catch (e) {
console.error('Failed to get cached requests', e);
return [];
}
}
// Сохранить закэшированные запросы
private saveCachedRequests(requests: CachedRequest[]) {
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(requests));
} catch (e) {
console.error('Failed to save cached requests', e);
}
}
// Удалить запрос из кэша
removeRequest(id: string): void {
const cachedRequests = this.getCachedRequests();
const filtered = cachedRequests.filter(req => req.id !== id);
this.saveCachedRequests(filtered);
}
// Увеличить счётчик попыток для запроса
incrementRetries(id: string): void {
const cachedRequests = this.getCachedRequests();
const updated = cachedRequests.map(req => {
if (req.id === id) {
return { ...req, retries: req.retries + 1 };
}
return req;
});
this.saveCachedRequests(updated);
}
// Синхронизировать все закэшированные запросы
async syncCachedRequests(apiBaseUrl: string): Promise<{ success: number; failed: number }> {
const cachedRequests = this.getCachedRequests();
let success = 0;
let failed = 0;
for (const request of cachedRequests) {
// Пропускаем запросы с превышенным лимитом попыток
if (request.retries >= MAX_RETRIES) {
this.removeRequest(request.id);
failed++;
continue;
}
try {
const fullUrl = request.url.startsWith('http')
? request.url
: `${apiBaseUrl}${request.url}`;
const options: RequestInit = {
method: request.method,
headers: {
'Content-Type': 'application/json',
},
};
if (request.body && (request.method === 'POST' || request.method === 'PUT')) {
options.body = JSON.stringify(request.body);
}
const response = await fetch(fullUrl, options);
if (response.ok) {
this.removeRequest(request.id);
success++;
} else {
this.incrementRetries(request.id);
failed++;
}
} catch (error) {
this.incrementRetries(request.id);
failed++;
}
}
return { success, failed };
}
// Очистить весь кэш
clearCache(): void {
localStorage.removeItem(CACHE_KEY);
}
// Получить количество закэшированных запросов
getCacheSize(): number {
return this.getCachedRequests().length;
}
}
// Создаём единственный экземпляр сервиса
export const offlineCacheService = new OfflineCacheService();

77
services/settingsService.ts Executable file
View File

@@ -0,0 +1,77 @@
/**
* Сервис для работы с настройками интеграций
* Хранит настройки в localStorage
*/
export interface DomaAISettings {
apiUrl: string;
/**
* Токен доступа к Doma AI (предпочтительный способ авторизации)
*/
token?: string;
/**
* Резервные поля для авторизации по логину/паролю (fallback)
*/
email?: string;
phone?: string;
password?: string;
}
export interface IntegrationSettings {
domaAI: DomaAISettings;
}
const SETTINGS_KEY = 'mkd_integration_settings';
export const settingsService = {
/**
* Получить настройки интеграций
*/
getIntegrationSettings(): IntegrationSettings | null {
try {
const stored = localStorage.getItem(SETTINGS_KEY);
if (stored) {
return JSON.parse(stored);
}
return null;
} catch (e) {
console.error('[settingsService] Ошибка при чтении настроек:', e);
return null;
}
},
/**
* Сохранить настройки интеграций
*/
saveIntegrationSettings(settings: IntegrationSettings): void {
try {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
} catch (e) {
console.error('[settingsService] Ошибка при сохранении настроек:', e);
}
},
/**
* Получить настройки Дома.АИ
*/
getDomaAISettings(): DomaAISettings | null {
const settings = this.getIntegrationSettings();
return settings?.domaAI || null;
},
/**
* Сохранить настройки Дома.АИ
*/
saveDomaAISettings(domaAISettings: DomaAISettings): void {
const currentSettings = this.getIntegrationSettings() || { domaAI: domaAISettings };
currentSettings.domaAI = domaAISettings;
this.saveIntegrationSettings(currentSettings);
},
/**
* Очистить настройки интеграций
*/
clearIntegrationSettings(): void {
localStorage.removeItem(SETTINGS_KEY);
},
};

272
services/storageService.ts Executable file
View File

@@ -0,0 +1,272 @@
import { Building, District } from "../types";
import { MOCK_BUILDINGS, MOCK_DISTRICTS } from "../constants";
const DB_KEY_BUILDINGS = 'mkd_buildings_v2';
const DB_KEY_DISTRICTS = 'mkd_districts_v2';
const DB_KEY_GLOBAL_PASSPORT_FIELDS = 'mkd_global_passport_fields';
export const storageService = {
// --- DISTRICTS ---
getDistricts: (): District[] => {
try {
const stored = localStorage.getItem(DB_KEY_DISTRICTS);
if (stored) {
const parsed = JSON.parse(stored);
return Array.isArray(parsed) ? parsed : [];
}
return [];
} catch (e) {
return [];
}
},
saveDistrict: (district: District): void => {
const all = storageService.getDistricts();
const index = all.findIndex(d => d.id === district.id);
let newAll;
if (index >= 0) {
newAll = [...all];
newAll[index] = district;
} else {
newAll = [...all, district];
}
localStorage.setItem(DB_KEY_DISTRICTS, JSON.stringify(newAll));
},
createDistrict: (data: Omit<District, 'id'>): District => {
const newDistrict: District = {
...data,
id: `d-${Date.now()}`
};
storageService.saveDistrict(newDistrict);
return newDistrict;
},
deleteDistrict: (id: string): void => {
const all = storageService.getDistricts();
const newAll = all.filter(d => d.id !== id);
localStorage.setItem(DB_KEY_DISTRICTS, JSON.stringify(newAll));
},
// --- BUILDINGS ---
getAllBuildings: (): Building[] => {
try {
const stored = localStorage.getItem(DB_KEY_BUILDINGS);
if (stored) {
const parsed = JSON.parse(stored);
return Array.isArray(parsed) ? parsed : [];
}
return [];
} catch (e) {
return [];
}
},
getBuildingsByDistrict: (districtId: string): Building[] => {
const all = storageService.getAllBuildings();
return all.filter(b => b.districtId === districtId);
},
getBuildingById: (id: string): Building | undefined => {
const all = storageService.getAllBuildings();
return all.find(b => b.id === id);
},
saveBuildingData: (updatedBuilding: Building): void => {
try {
const all = storageService.getAllBuildings();
const index = all.findIndex(b => b.id === updatedBuilding.id);
const toSave = {
...updatedBuilding,
isDirty: true,
lastSync: Date.now()
};
let newAll;
if (index >= 0) {
newAll = [...all];
newAll[index] = toSave;
} else {
newAll = [...all, toSave];
}
localStorage.setItem(DB_KEY_BUILDINGS, JSON.stringify(newAll));
} catch (e) {
console.error("Failed to save to storage", e);
}
},
createBuilding: (data: Partial<Building> & { address: string, districtId: string }): Building => {
const id = `b-${Date.now()}`;
const newBuilding: Building = {
id,
districtId: data.districtId,
imageUrl: data.imageUrl || 'https://picsum.photos/800/600',
nps: 0,
passport: {
address: data.address,
apartmentsCount: 0,
general: {
address: data.address,
fiasCode: '',
constructionYear: new Date().getFullYear(),
commissionYear: new Date().getFullYear(),
seriesType: 'Индивидуальный',
floors: 1,
undergroundFloors: 0,
totalArea: 0,
livingArea: 0,
nonLivingArea: 0,
commonArea: 0,
cadastralNumberBuild: '',
cadastralNumberLand: ''
},
construction: { foundationType: '', foundationMaterial: '', wallMaterial: '', floorMaterial: '', roofType: '', roofMaterial: '', roofArea: 0, facadeType: '', facadeInsulation: false, windowType: '' },
engineering: { heatingType: '', heatingWiring: '', hasITP: false, waterSupplyMaterial: '', waterSupplyType: '', sewerMaterial: '', electricityEntries: 1, hasVRU: false, gasType: '', ventilationType: '' },
odpu: { customFields: {} },
meters: [],
lifts: [],
land: { area: 0, hasPlayground: false, hasSportsGround: false, hasParking: false, hasFencing: false, hasContainerSite: false },
management: {
contractDate: '',
contractNumber: '',
servicesList: [],
tariffMaintenance: 0,
reserveFund: 5.00, // Значение по умолчанию 5%
serviceContracts: [] // Initialize empty array
},
...data.passport
},
staff: [],
entrances: [],
commonSections: [],
accounts: [],
financials: { balance: 0, debt: 0, collectionRate: 0, topDebtors: [], invoices: [] },
requests: { new: 0, inProgress: 0, overdue: 0 },
inspectionHistory: [],
tasks: [],
annualPlan: [],
inventory: [],
writeOffHistory: [],
residents: [],
reports: [],
isDirty: true,
...data
};
storageService.saveBuildingData(newBuilding);
return newBuilding;
},
deleteBuilding: (id: string): void => {
const all = storageService.getAllBuildings();
const newAll = all.filter(b => b.id !== id);
localStorage.setItem(DB_KEY_BUILDINGS, JSON.stringify(newAll));
},
syncWithServer: async (): Promise<boolean> => {
return new Promise((resolve) => {
setTimeout(() => {
const all = storageService.getAllBuildings();
const cleaned = all.map(b => ({ ...b, isDirty: false }));
localStorage.setItem(DB_KEY_BUILDINGS, JSON.stringify(cleaned));
resolve(true);
}, 1500);
});
},
clearLocal: () => {
localStorage.removeItem(DB_KEY_BUILDINGS);
localStorage.removeItem(DB_KEY_DISTRICTS);
},
// --- GLOBAL PASSPORT FIELDS (для всех домов) ---
getGlobalPassportFields: (): { [section: string]: { [fieldName: string]: { value: any; type: string; files?: string[] } } } => {
try {
const stored = localStorage.getItem(DB_KEY_GLOBAL_PASSPORT_FIELDS);
if (stored) return JSON.parse(stored);
return {};
} catch (e) {
return {};
}
},
saveGlobalPassportField: (section: 'general' | 'construction' | 'engineering' | 'odpu' | 'land' | 'management', fieldName: string, fieldType: 'text' | 'number' | 'checkbox'): void => {
const globalFields = storageService.getGlobalPassportFields();
if (!globalFields[section]) {
globalFields[section] = {};
}
const defaultValue = fieldType === 'number' ? 0 : fieldType === 'checkbox' ? false : '';
globalFields[section][fieldName] = {
value: defaultValue,
type: fieldType,
files: []
};
localStorage.setItem(DB_KEY_GLOBAL_PASSPORT_FIELDS, JSON.stringify(globalFields));
// Применяем это поле ко всем домам
const allBuildings = storageService.getAllBuildings();
allBuildings.forEach(building => {
const sectionData = building.passport[section] as any;
const customFields = { ...(sectionData.customFields || {}) };
if (!customFields[fieldName]) {
customFields[fieldName] = {
value: defaultValue,
type: fieldType,
files: []
};
const updatedSection = { ...sectionData, customFields };
building.passport[section] = updatedSection as any;
}
});
localStorage.setItem(DB_KEY_BUILDINGS, JSON.stringify(allBuildings));
},
updateGlobalPassportField: (section: 'general' | 'construction' | 'engineering' | 'odpu' | 'land' | 'management', fieldName: string, value: any, files?: string[]): void => {
const globalFields = storageService.getGlobalPassportFields();
if (globalFields[section] && globalFields[section][fieldName]) {
globalFields[section][fieldName].value = value;
if (files !== undefined) {
globalFields[section][fieldName].files = files;
}
localStorage.setItem(DB_KEY_GLOBAL_PASSPORT_FIELDS, JSON.stringify(globalFields));
// Обновляем это поле во всех домах
const allBuildings = storageService.getAllBuildings();
allBuildings.forEach(building => {
const sectionData = building.passport[section] as any;
const customFields = { ...(sectionData.customFields || {}) };
if (customFields[fieldName]) {
customFields[fieldName].value = value;
if (files !== undefined) {
customFields[fieldName].files = files;
}
const updatedSection = { ...sectionData, customFields };
building.passport[section] = updatedSection as any;
}
});
localStorage.setItem(DB_KEY_BUILDINGS, JSON.stringify(allBuildings));
}
},
deleteGlobalPassportField: (section: 'general' | 'construction' | 'engineering' | 'odpu' | 'land' | 'management', fieldName: string): void => {
const globalFields = storageService.getGlobalPassportFields();
if (globalFields[section] && globalFields[section][fieldName]) {
delete globalFields[section][fieldName];
if (Object.keys(globalFields[section]).length === 0) {
delete globalFields[section];
}
localStorage.setItem(DB_KEY_GLOBAL_PASSPORT_FIELDS, JSON.stringify(globalFields));
// Удаляем это поле из всех домов
const allBuildings = storageService.getAllBuildings();
allBuildings.forEach(building => {
const sectionData = building.passport[section] as any;
const customFields = { ...(sectionData.customFields || {}) };
if (customFields[fieldName]) {
delete customFields[fieldName];
const updatedSection = { ...sectionData, customFields };
building.passport[section] = updatedSection as any;
}
});
localStorage.setItem(DB_KEY_BUILDINGS, JSON.stringify(allBuildings));
}
}
};