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

27
.env.example Executable file
View File

@@ -0,0 +1,27 @@
# API Configuration — ваш Node/Express бэкенд (НЕ n8n).
# Локально: http://localhost:4000/api или /api (если включён proxy в vite.config).
# В dev при /api запросы идут через Vite proxy на localhost:4000.
VITE_API_BASE_URL=http://localhost:4000/api
# Auth (backend)
JWT_SECRET=your-secret-change-in-production
JWT_EXPIRES_IN=7d
# Turnstile captcha (optional): https://dash.cloudflare.com/?to=/:account/turnstile
TURNSTILE_SECRET_KEY=
VITE_TURNSTILE_SITE_KEY=
# Doma AI API Configuration (PRODUCTION)
# ВАЖНО: Укажите URL вашего продакшн инстанса Doma AI
# Пример для продакшена: https://your-domain.doma.ai/admin/api
# Для тестирования можно использовать: https://condo.d.doma.ai/admin/api
VITE_DOMA_AI_API_URL=https://your-domain.doma.ai/admin/api
# Учетные данные для авторизации в Doma AI
# Используйте email и пароль ИЛИ телефон и пароль
# Для продакшена используйте учетные данные вашей организации в Doma AI
VITE_DOMA_AI_EMAIL=your-email@example.com
VITE_DOMA_AI_PASSWORD=your-password
# ИЛИ используйте телефон:
# VITE_DOMA_AI_PHONE=+79991234567
# VITE_DOMA_AI_PASSWORD=your-password

13
.env.production Executable file
View File

@@ -0,0 +1,13 @@
VITE_API_BASE_URL=http://localhost:4000/api
# Doma AI API Configuration (PRODUCTION)
# ВАЖНО: Укажите URL вашего продакшн инстанса Doma AI
# Пример: https://your-domain.doma.ai/admin/api
# Для тестирования можно использовать: https://condo.d.doma.ai/admin/api
VITE_DOMA_AI_API_URL=
# Учетные данные для авторизации в Doma AI
# Используйте email и пароль ИЛИ телефон и пароль
VITE_DOMA_AI_EMAIL=
VITE_DOMA_AI_PASSWORD=
VITE_DOMA_AI_PHONE=

24
.gitignore vendored Executable file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

1218
App.tsx Executable file

File diff suppressed because it is too large Load Diff

170
README.md Executable file
View File

@@ -0,0 +1,170 @@
# Центр управления жилым фондом (МКД)
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
Веб-приложение для комплексного управления многоквартирными домами (МКД), включающее модули для управления объектами, финансами, кадрами, юридическими вопросами, PR-деятельностью и офисной работой.
## 🚀 Возможности
### Основные модули:
- **📊 Дашборд** - Общая сводка и аналитика по всем направлениям
- **🏢 Объекты** - Управление зданиями, характеристиками, осмотрами, планами работ
- **📝 Заявки** - Реестр заявок жильцов, контроль качества, диспетчеризация
- **💰 Финансы** - Управление счетами, платежами, дебиторской задолженностью, отчетностью
- **👥 HR** - Управление персоналом, вакансиями, наймом, зарплатами
- **⚖️ Юридический** - Договоры, судебные дела, взыскание долгов, доверенности
- **📢 PR** - Управление событиями, обратной связью жильцов, SMM, негативными ситуациями
- **🏛️ Офис** - Документооборот, управление помещениями, база знаний, закупки
- **🔧 Развитие** - Технический аудит, маркетинговые кампании, работа с ОСС
### Система ролей:
Приложение поддерживает различные роли пользователей с разными уровнями доступа:
- **Директор** - Полный доступ ко всем модулям
- **Главный инженер** - Дашборд, объекты, заявки, офис, развитие
- **Мастер** - Объекты, заявки
- **Юрист** - Дашборд, юридический, объекты, заявки
- **Финансист** - Дашборд, финансы, офис, объекты
- **HR-менеджер** - Дашборд, HR, офис
- **PR-менеджер** - Дашборд, PR, заявки
## 🛠️ Технологии
- **React 19** - UI библиотека
- **TypeScript** - Типизация
- **Vite** - Сборщик и dev-сервер
- **Tailwind CSS** - Стилизация
- **Lucide React** - Иконки
- **html2canvas & jsPDF** - Экспорт в PDF
## 📋 Требования
- **Node.js** (рекомендуется версия 18+)
## 🚀 Установка и запуск
### 1. Установка зависимостей
```bash
npm install
```
### 2. Настройка переменных окружения
Создайте файл `.env.local` в корне проекта на основе `.env.example`:
```env
# API Configuration
VITE_API_BASE_URL=http://localhost:4000/api
# Doma AI API Configuration
# URL API Doma AI (по умолчанию используется тестовый стенд)
VITE_DOMA_AI_API_URL=https://condo.d.doma.ai/admin/api
# Учетные данные для авторизации в Doma AI
# Используйте email и пароль ИЛИ телефон и пароль
VITE_DOMA_AI_EMAIL=your-email@example.com
VITE_DOMA_AI_PASSWORD=your-password
# ИЛИ используйте телефон:
# VITE_DOMA_AI_PHONE=+79991234567
```
**Настройка интеграции с Doma AI (PRODUCTION):**
1. Получите URL API вашего продакшн инстанса Doma AI (обычно: `https://your-domain.doma.ai/admin/api`)
2. Убедитесь, что у вас есть учетные данные для доступа к API
3. Укажите URL API и учетные данные в переменных окружения:
- `VITE_DOMA_AI_API_URL` - URL вашего инстанса Doma AI
- `VITE_DOMA_AI_EMAIL` / `VITE_DOMA_AI_PHONE` - учетные данные
- `VITE_DOMA_AI_PASSWORD` - пароль
4. При первом запуске приложение автоматически авторизуется в Doma AI
5. Заявки будут автоматически загружаться из Doma AI в раздел "Заявки"
**Для тестирования** можно использовать тестовый стенд: https://condo.d.doma.ai/
### 3. Запуск в режиме разработки
```bash
npm run dev
```
Приложение будет доступно по адресу: `http://localhost:3000`
### 4. Сборка для продакшена
```bash
npm run build
```
### 5. Просмотр production сборки
```bash
npm run preview
```
## 📁 Структура проекта
```
├── components/ # React компоненты
│ ├── applications/ # Модуль заявок
│ ├── building/ # Компоненты для работы со зданиями
│ ├── development/ # Модуль развития
│ ├── finance/ # Финансовый модуль
│ ├── hr/ # HR модуль
│ ├── legal/ # Юридический модуль
│ ├── objects/ # Компоненты объектов
│ ├── office/ # Офисный модуль
│ └── pr/ # PR модуль
├── services/ # Сервисы (API, storage, etc.)
├── types.ts # TypeScript типы
├── constants.tsx # Константы и моки
├── App.tsx # Главный компонент
├── index.tsx # Точка входа
└── vite.config.ts # Конфигурация Vite
```
## 🔧 Конфигурация
### Vite конфигурация
Проект настроен для работы с прокси-сервером API:
- Dev сервер: `http://localhost:3000`
- API прокси: `/api``https://n8n.iieasy.ru/`
Настройки можно изменить в `vite.config.ts`.
## 📝 Особенности
- **Локальное хранилище** - Сохранение состояния выбранных зданий и активных вкладок
- **Адаптивный дизайн** - Поддержка мобильных устройств
- **Система ролей** - Контроль доступа на основе ролей пользователей
- **Экспорт данных** - Возможность экспорта отчетов в PDF
- **Интеграция с Doma AI** - Автоматическая синхронизация заявок через GraphQL API
## 🎯 Разработка
### Добавление нового модуля
1. Создайте компонент модуля в `components/`
2. Добавьте навигационный элемент в `constants.tsx`
3. Добавьте обработку в `App.tsx` в функции `renderContent()`
4. Настройте права доступа в `ROLE_ACCESS` в `App.tsx`
### Работа с типами
Все типы определены в `types.ts`. При добавлении новых сущностей обновите соответствующие типы.
## 📄 Лицензия
Проект разработан для внутреннего использования.
## 🔗 Ссылки
- [AI Studio](https://ai.studio/apps/drive/1jOVxm30BucP1pvw8ODxuHe3FC-Xaww9E)
- [Документация Doma AI API](https://developers.doma.ai/ru/docs/index)
- [Doma AI API Playground](https://condo.d.doma.ai/admin/api)

4
backend/.env Executable file
View File

@@ -0,0 +1,4 @@
PORT=4000
DATABASE_URL=postgres://mkd_user:Nemo348ax@@localhost:5432/mkd_control_center
DOMA_API_URL=https://condo.d.doma.ai/admin/api
DOMA_API_TOKEN=d-7Tf8g0r277ismVAmBnVOZvxaTBYCkz.CLBfWjkEc503Df4Nifw558wKi3w1XnvpF/L4zIDqylc

173
backend/1c-integration-options.md Executable file
View File

@@ -0,0 +1,173 @@
# Варианты интеграции с 1С для получения данных по зарплате
## Текущая ситуация
В системе уже есть:
- ✅ Обработчик файлов (CSV/XLSX) - `fileProcessor.js`
- ✅ Таблица `employees` с полем `salary`
- ✅ Таблица `employee_accounting_data` (ИНН, СНИЛС, банковские реквизиты)
- ✅ API для загрузки финансовых отчетов
## Варианты интеграции
### 1. 📄 **Импорт файлов (Excel/CSV) - РЕКОМЕНДУЕТСЯ**
**Сложность:** ⭐ Низкая
**Время внедрения:** 1-2 дня
**Требования:** 1С может экспортировать отчеты в Excel/CSV
**Как работает:**
- В 1С настраивается выгрузка отчета по зарплате в Excel/CSV
- Пользователь загружает файл через веб-интерфейс
- Система автоматически парсит и обновляет данные сотрудников
**Плюсы:**
- ✅ Уже реализована базовая инфраструктура
-Не требует изменений в 1С
- ✅ Простое внедрение
- ✅ Работает с любой версией 1С
**Минусы:**
- ⚠️ Ручная загрузка файлов
- ⚠️ Нет автоматической синхронизации
**Что нужно сделать:**
1. Расширить `fileProcessor.js` для обработки зарплатных данных
2. Добавить маппинг полей из 1С отчета
3. Создать API endpoint для обновления зарплат сотрудников
4. Добавить UI для загрузки зарплатных отчетов
---
### 2. 🌐 **REST API 1С (HTTP-сервис)**
**Сложность:** ⭐⭐ Средняя
**Время внедрения:** 3-5 дней
**Требования:** 1С:Предприятие 8.3+ с настроенным HTTP-сервисом
**Как работает:**
- В 1С создается HTTP-сервис, который отдает данные по зарплате
- Node.js backend делает запросы к 1С API
- Автоматическая синхронизация по расписанию
**Плюсы:**
- ✅ Автоматическая синхронизация
- ✅ Актуальные данные в реальном времени
- ✅ Можно настроить расписание обновлений
**Минусы:**
- ⚠️ Требует настройки HTTP-сервиса в 1С
- ⚠️ Нужен доступ к серверу 1С
- ⚠️ Требуется настройка безопасности
**Что нужно сделать:**
1. Настроить HTTP-сервис в 1С
2. Создать модуль `1cApiClient.js` для работы с API
3. Добавить endpoint `/api/salary/sync-from-1c`
4. Настроить cron-задачу для автоматической синхронизации
---
### 3. 🔌 **COM/OLE соединение (Windows)**
**Сложность:** ⭐⭐⭐ Высокая
**Время внедрения:** 5-7 дней
**Требования:** Windows сервер, 1С установлена локально
**Как работает:**
- Node.js использует COM-объекты для подключения к 1С
- Прямое обращение к данным через OLE
**Плюсы:**
- ✅ Прямой доступ к данным
-Не нужны промежуточные файлы
**Минусы:**
- ⚠️ Работает только на Windows
- ⚠️ Требует установленной 1С на сервере
- ⚠️ Сложная настройка
- ⚠️ Проблемы с производительностью
**Не рекомендуется** для веб-приложения
---
### 4. 🗄️ **ODBC/OLE DB подключение к базе 1С**
**Сложность:** ⭐⭐⭐ Высокая
**Время внедрения:** 5-7 дней
**Требования:** 1С на SQL Server, доступ к БД
**Как работает:**
- Прямое подключение к базе данных 1С через ODBC
- SQL-запросы к таблицам 1С
**Плюсы:**
- ✅ Прямой доступ к данным
- ✅ Можно использовать SQL-запросы
**Минусы:**
- ⚠️ Требует знания структуры БД 1С
- ⚠️ Зависит от версии 1С
- ⚠️ Проблемы с безопасностью
- ⚠️ Может сломаться при обновлении 1С
**Не рекомендуется** - хрупкое решение
---
## Рекомендация
### 🎯 **Гибридный подход: Файлы + HTTP-сервис**
**Этап 1 (быстрое внедрение):**
- Расширить существующую систему импорта файлов для зарплатных данных
- Пользователи загружают отчеты вручную
**Этап 2 (автоматизация):**
- Настроить HTTP-сервис в 1С
- Добавить автоматическую синхронизацию по расписанию
- Файлы остаются как резервный вариант
---
## Структура данных для зарплатного отчета
### Поля, которые нужно получать из 1С:
```typescript
interface SalaryReportRow {
// Идентификация сотрудника
employeeIdentifier: string; // ФИО, ИНН, табельный номер
inn?: string; // ИНН (для точного сопоставления)
snils?: string; // СНИЛС
// Зарплатные данные
period: string; // Период (месяц/год)
baseSalary: number; // Оклад
actualSalary: number; // Фактическая зарплата
bonuses?: number; // Премии
deductions?: number; // Удержания
netSalary?: number; // К выплате
// Дополнительно
workedDays?: number; // Отработано дней
workedHours?: number; // Отработано часов
vacationDays?: number; // Дни отпуска
sickLeaveDays?: number; // Дни больничного
}
```
---
## Следующие шаги
1. **Определить формат отчета из 1С:**
- Какие поля содержит отчет?
- В каком формате экспортируется (Excel, CSV)?
- Есть ли возможность настроить выгрузку?
2. **Выбрать вариант интеграции:**
- Начать с импорта файлов (быстро)
- Планировать HTTP-сервис (автоматизация)
3. **Реализовать:**
- Расширить fileProcessor для зарплатных данных
- Создать маппинг полей
- Добавить UI для загрузки
- Настроить обновление данных сотрудников

135
backend/HOW_REPORTS_WORK.md Executable file
View File

@@ -0,0 +1,135 @@
# Как работает обработка отчетов из 1С
## Оборотно-сальдовая ведомость по счету 20
### Распознавание файла
Файл `Оборотно_сальдовая_ведомость_по_счету_20_за_2025_г_ОООружба.csv` **автоматически распознается** как `balance_sheet` по ключевым словам в названии:
- "оборотн"
- "сальд"
- "ведомост"
### Формат CSV (схема колонок и чисел)
- **Разделитель**: точка с запятой (`;`).
- **Кодировка**: UTF-8 (при наличии BOM он автоматически убирается).
- **Строки 19**: метаданные и заголовки (организация, «Счет, Наименование счета», «Обороты за период», «Статьи затрат» и т.д.). Строка начала данных определяется автоматически (по подстроке «Обороты за период» / «Счет, Наименование» или по первой строке, где в колонке 0 есть текст, а в колонке 3 или 4 — число).
**Колонки (индексы 06):**
| Индекс | Содержимое |
|--------|------------|
| 0 | Наименование (адрес дома / номенклатурная группа / статья затрат) |
| 1, 2 | Сальдо на начало периода (Дебет, Кредит), часто пусто |
| **3** | **Обороты за период — Дебет** (источник расходов по счёту 20) |
| 4 | Обороты за период — Кредит |
| 5, 6 | Сальдо на конец периода (Дебет, Кредит) |
**Формат чисел**: пробел — разделитель тысяч, запятая — десятичный знак (например, `2 229,20`). Парсер удаляет пробелы и заменяет запятую на точку.
**Пример строки данных:**
`Булата Имашева 6/1;;;2 229,20;;2 229,20;` — адрес в колонке 0, оборот по дебету (расход) в колонке 3: 2 229,20.
### Куда сохраняются данные
1. **Таблица `financial_reports`**:
- Запись о загруженном файле
- Поля: `id`, `filename`, `file_type`, `uploaded_by`, `status`, `report_type`, `uploaded_at`
- Статус: `processing``completed` / `partial` / `failed`
2. **Таблица `building_financial_data`**:
- Финансовые данные по каждому дому
- Поля:
- `building_id` - ID дома из таблицы `buildings`
- `report_id` - ID отчета из `financial_reports`
- `period_start`, `period_end`, `period_type` - период (извлекается из названия файла)
- `total_income` - общий доход
- `total_expenses` - общие расходы
- `expenses_by_items` - JSONB с разбивкой по статьям: `{"Группа > Статья": сумма}`
- `balance` - баланс
- `metadata` - JSONB с полной структурой групп и статей
### Как извлекаются адреса
Функция `isBuildingAddress()` определяет адрес дома по следующим признакам:
- Содержит буквы (название улицы) И цифры (номер дома)
- Паттерны: `"6/1"`, `"Булата Имашева 6/1"`, `"Авроры 5/12"`
- НЕ является счетом (20, 20.01)
- НЕ является статьей затрат
**Важно**: Чтобы данные по адресу попали в отчёт, адрес из файла должен быть найден в системе. Поиск выполняется в таком порядке:
1. Таблица **`doma_address_mappings`** — по нормализованному адресу (LOWER, TRIM) ищется `building_id`. Это позволяет явно связать адрес из 1С/ОСВ (например, «Авроры 5/12», «Булата Имашева 6/1») с домом в системе.
2. Таблица **`buildings`** — по полному совпадению `data->'passport'->>'address'`, затем без учёта регистра и пробелов, затем по нормализованному адресу (без «ул.», «д.», запятых), затем по основной части адреса (ILIKE).
Если дом не найден ни одним способом, данные по этому адресу пропускаются и адрес добавляется в `error_log` отчета.
### Примеры адресов из файла
-**"Булата Имашева 6/1"** - распознается (есть цифры и буквы)
-**"Авроры 5/12"** - распознается (есть цифры и буквы)
- ⚠️ **"Зеленая Роща"** - НЕ распознается как адрес дома (нет цифр в первой колонке)
- Это может быть участок или комплекс, но не отдельный дом
- Если это участок, данные по нему не сохранятся в `building_financial_data`
### Структура данных в `expenses_by_items`
```json
{
"Административно-управленческие расходы > Набор персонала": 805.84,
"Административно-управленческие расходы > Налоги": 4416.06,
"Текущее обслуживание общедомового имущества > Ремонт кровли": 34000.00,
...
}
```
### Где посмотреть загруженные данные
1. **Список отчетов**: Вкладка "Отчеты" → "Загруженные отчеты"
2. **Детали отчета**: Клик на отчет → показывает дома, участки, детальную разбивку
3. **Данные по дому**: В паспорте дома → раздел "Финансы" → данные из `building_financial_data`
### Проблемы и решения
**Проблема**: "Дом не найден в базе"
- **Причина**: Адрес из файла не найден ни в `doma_address_mappings`, ни в таблице `buildings` (с учётом нормализации).
- **Решение**: Добавить запись в `doma_address_mappings` (адрес из ОСВ`building_id`), либо привести адрес дома в паспорте к формату из файла, либо добавить дом вручную.
**Проблема**: "Зеленая Роща" не распознается
- **Причина**: Нет цифр в первой колонке (это участок, а не дом)
- **Решение**: Если это участок, данные должны быть привязаны к конкретным домам внутри участка
---
## Оборотно-сальдовая ведомость по счёту 76.06 (лицевые счета жителей)
### Назначение
ОСВ по счёту 76.06 содержит **лицевые счета** (жители/собственники): в 1С колонка может называться «Контрагенты», по смыслу это лицевые счета с суммами (обороты, сальдо).
### Распознавание файла
- По названию файла: подстроки `"76"`, `"76.06"`, `"счету 76"` (без учёта регистра) — тип `balance_sheet_76`.
- По фронту: при выборе типа «ОСВ по счёту 76» передаётся `detailedReportType: 'balance_sheet_76'`.
### Формат CSV (схема колонок)
- Разделитель `;`, кодировка UTF-8, BOM убирается.
- Строки 18: метаданные, заголовки «Счет», «Контрагенты», «Дебет», «Кредит». Начало данных определяется автоматически (по «Обороты за период» / «Контрагенты» или по первой строке с числом в колонке 3/4).
- Колонки те же, что у ОСВ 20: 0 — наименование (лицевой счёт: код + ФИО (л/с) или просто ФИО/ИП/юрлицо); 1, 3, 4, 5, 6 — сальдо на начало, обороты дебет/кредит, сальдо на конец. Пропускаются строки с колонкой 0 = `76.06` или `Итого`.
### Куда сохраняются данные
1. **Таблица `financial_reports`**: запись с `report_type = 'balance_sheet_76'`.
2. **Таблица `report_76_rows`**: по одной строке на каждый лицевой счёт (report_id, row_index, account_label, account_ls, saldo_start_debet, turnover_debet, turnover_credit, saldo_end_debet, saldo_end_credit).
3. **Таблица `building_personal_account_mappings`** (сопоставление домов и лицевых счетов): привязка лицевого счёта к дому (building_id, account_ls, account_label, apartment). Используется для фильтра «ОСВ 76 по дому» и отображения данных 76 в карточке дома. Заполняется вручную или импортом.
### API
- `GET /api/finance/reports/:reportId/balance-sheet-76-rows` — строки отчёта (лицевые счета). Опциональный параметр `buildingId`: при указании возвращаются только строки, чей лицевой счёт привязан к этому дому в `building_personal_account_mappings`.
- `GET /api/finance/buildings/:buildingId/personal-accounts` — лицевые счета, привязанные к дому.
- `POST /api/finance/building-personal-account-mappings` — добавить/обновить привязку (body: building_id, account_ls, опционально account_label, apartment).
### Где посмотреть
1. **Список отчетов**: Финансы → Отчеты; фильтр «Лицевые счета (ОСВ 76)».
2. **Детали отчёта**: Клик на отчёт ОСВ 76 → таблица лицевых счетов (колонки: Лицевой счёт, Сальдо на начало, Обороты дебет/кредит, Сальдо на конец). При наличии маппинга можно добавить фильтр по дому.

239
backend/INTEGRATION_SALARY_1C.md Executable file
View File

@@ -0,0 +1,239 @@
# Интеграция зарплатных данных из 1С - Инструкция по внедрению
## Шаг 1: Применить миграцию БД
Выполните SQL миграцию для создания таблицы истории зарплат:
```bash
psql -d mkd_control_center -f backend/migrate_salary_history.sql
```
Или вручную через pgAdmin/psql выполните содержимое файла `backend/migrate_salary_history.sql`
## Шаг 2: Добавить SalaryProcessor в server.js
В файле `backend/server.js` добавьте:
```javascript
// В начале файла, после других require
const SalaryProcessor = require('./salaryProcessor');
const salaryProcessor = new SalaryProcessor(pool);
```
## Шаг 3: Добавить API endpoint для загрузки зарплатных отчетов
Добавьте в `backend/server.js` после существующих финансовых endpoints:
```javascript
// POST /api/salary/upload-report - загрузка зарплатного отчета из 1С
app.post(`${API_PREFIX}/salary/upload-report`, upload.single('file'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'Файл не загружен' });
}
const fileType = path.extname(req.file.originalname).toLowerCase() === '.csv' ? 'CSV' : 'XLSX';
const uploadedBy = req.body.uploadedBy || 'System';
// Создаем запись об отчете
const reportResult = await query(
`INSERT INTO financial_reports (filename, file_type, uploaded_by, status)
VALUES ($1, $2, $3, 'processing')
RETURNING id`,
[req.file.originalname, fileType, uploadedBy]
);
const reportId = reportResult[0].id;
// Парсим файл
let rows = [];
if (fileType === 'CSV') {
rows = await fileProcessor.parseCSV(req.file.path);
} else {
rows = await fileProcessor.parseXLSX(req.file.path);
}
// Получаем маппинг полей (можно передать в body или использовать по умолчанию)
const mapping = req.body.mapping ? JSON.parse(req.body.mapping) : {
columnMappings: {
// Пример маппинга - настройте под ваш формат 1С
'ФИО': 'employeeIdentifier',
'ИНН': 'inn',
'Период': 'period',
'Оклад': 'baseSalary',
'Начислено': 'actualSalary',
'Премия': 'bonuses',
'Удержано': 'deductions',
'К выплате': 'netSalary',
'Отработано дней': 'workedDays'
}
};
// Обрабатываем зарплатные данные
const result = await salaryProcessor.processSalaryData(rows, mapping, reportId);
// Обновляем статус отчета
await query(
`UPDATE financial_reports
SET status = $1, processed_rows = $2, error_rows = $3, error_log = $4
WHERE id = $5`,
[
result.errorRows > 0 && result.processedRows > 0 ? 'partial' :
result.errorRows > 0 ? 'failed' : 'completed',
result.processedRows,
result.errorRows,
JSON.stringify(result.errors),
reportId
]
);
// Удаляем файл после обработки
fs.unlinkSync(req.file.path);
res.json({
success: true,
reportId,
...result
});
} catch (error) {
console.error('Error processing salary report:', error);
res.status(500).json({
error: 'Ошибка обработки зарплатного отчета',
details: error.message
});
}
});
// GET /api/salary/history/:employeeId - получить историю зарплат сотрудника
app.get(`${API_PREFIX}/salary/history/:employeeId`, async (req, res) => {
try {
const { employeeId } = req.params;
const { year, month } = req.query;
let queryText = `
SELECT
id,
period_month AS "periodMonth",
period_year AS "periodYear",
base_salary AS "baseSalary",
actual_salary AS "actualSalary",
bonuses,
deductions,
net_salary AS "netSalary",
worked_days AS "workedDays",
worked_hours AS "workedHours",
vacation_days AS "vacationDays",
sick_leave_days AS "sickLeaveDays",
metadata,
imported_at AS "importedAt"
FROM employee_salary_history
WHERE employee_id = $1
`;
const params = [employeeId];
if (year) {
queryText += ` AND period_year = $${params.length + 1}`;
params.push(year);
}
if (month) {
queryText += ` AND period_month = $${params.length + 1}`;
params.push(month);
}
queryText += ' ORDER BY period_year DESC, period_month DESC';
const result = await query(queryText, params);
res.json(result);
} catch (error) {
console.error('Error fetching salary history:', error);
res.status(500).json({ error: 'Ошибка получения истории зарплат' });
}
});
```
## Шаг 4: Формат файла из 1С
### Пример структуры Excel/CSV файла из 1С:
| ФИО | ИНН | Период | Оклад | Начислено | Премия | Удержано | К выплате | Отработано дней |
|-----|-----|--------|-------|-----------|--------|----------|-----------|-----------------|
| Иванов Иван Иванович | 123456789012 | 01.2024 | 50000 | 55000 | 5000 | 7150 | 47850 | 22 |
| Петров Петр Петрович | 987654321098 | 01.2024 | 60000 | 65000 | 5000 | 8450 | 56550 | 22 |
### Важные моменты:
1. **Идентификация сотрудника:**
- Приоритет: ИНН > СНИЛС > ФИО
- ФИО должно совпадать с данными в системе (можно частичное совпадение)
2. **Формат периода:**
- Поддерживается: "01.2024", "01/2024", "январь 2024", "2024-01"
- Рекомендуется: "MM.YYYY" (например, "01.2024")
3. **Числовые поля:**
- Могут быть с пробелами: "50 000"
- Могут быть с запятой: "50,000"
- Автоматически конвертируются
## Шаг 5: Настройка маппинга полей
Если названия колонок в вашем файле 1С отличаются, настройте маппинг:
```javascript
const mapping = {
columnMappings: {
'ФИО сотрудника': 'employeeIdentifier',
'ИНН сотрудника': 'inn',
'Месяц расчета': 'period',
'Оклад по штатному расписанию': 'baseSalary',
'Начислено всего': 'actualSalary',
// ... и т.д.
}
};
```
## Шаг 6: Тестирование
1. Экспортируйте отчет по зарплате из 1С в Excel/CSV
2. Загрузите файл через API:
```bash
curl -X POST http://localhost:4000/api/salary/upload-report \
-F "file=@salary_report.xlsx" \
-F "uploadedBy=Admin"
```
3. Проверьте результат:
- Статус обработки
- Количество обработанных строк
- Ошибки (если есть)
## Шаг 7: Создание UI компонента (опционально)
Создайте React компонент для загрузки зарплатных отчетов, аналогичный `ReportUploader.tsx`:
```typescript
// components/hr/SalaryReportUploader.tsx
// Аналогично существующему ReportUploader, но для зарплатных данных
```
## Автоматизация (будущее)
После настройки HTTP-сервиса в 1С можно добавить автоматическую синхронизацию:
```javascript
// В cron или по расписанию
app.post(`${API_PREFIX}/salary/sync-from-1c`, async (req, res) => {
// Запрос к HTTP-сервису 1С
// Обработка ответа
// Сохранение данных
});
```
## Поддержка
При возникновении проблем:
1. Проверьте формат файла из 1С
2. Проверьте маппинг полей
3. Проверьте логи ошибок в консоли
4. Убедитесь, что сотрудники существуют в системе и имеют ИНН/СНИЛС

26
backend/README_MIGRATION.md Executable file
View File

@@ -0,0 +1,26 @@
# Миграция базы данных для событий кандидатов
## Проблема
Если вы видите ошибку `relation "candidate_events" does not exist`, это означает, что таблица событий кандидатов еще не создана в базе данных.
## Решение
Выполните SQL скрипт миграции:
```bash
psql -d your_database_name -f backend/migrate_candidate_events.sql
```
Или выполните скрипт напрямую в вашем клиенте PostgreSQL (pgAdmin, DBeaver и т.д.).
## Файл миграции
`backend/migrate_candidate_events.sql`
## Что создается
- Тип `candidate_event_type` (ENUM) - типы событий
- Тип `candidate_event_result` (ENUM) - результаты событий
- Таблица `candidate_events` - события кандидатов
- Индексы для оптимизации запросов
## После выполнения миграции
Перезапустите backend сервер, и функционал событий кандидатов заработает.

189
backend/aiChatService.js Executable file
View File

@@ -0,0 +1,189 @@
/**
* Сервис ИИ-чата: системный промпт, загрузка истории, вызов ai.iieasy.ru (OpenAI-совместимый API), цикл tool_calls.
*/
const axios = require('axios');
const { getToolsSchema, runTool } = require('./aiToolsRegistry');
const AI_CHAT_URL = process.env.AI_CHAT_URL || 'https://ai.iieasy.ru/v1/chat/completions';
const AI_API_KEY = process.env.AI_API_KEY || '';
const AI_MODEL = process.env.AI_MODEL || 'gpt-4o-mini';
const MAX_TOOL_ITERATIONS = 5;
const HISTORY_MESSAGES_LIMIT = 25;
const REQUEST_TIMEOUT_MS = 60000;
function buildSystemPrompt(userContext) {
const { userName = 'Пользователь', role = '', allowedSections = [] } = userContext;
const sections = Array.isArray(allowedSections) && allowedSections.length ? allowedSections.join(', ') : 'не заданы';
return `Ты помощник в системе управления МКД (многоквартирными домами). Пользователь: ${userName}, роль: ${role}. Разрешённые разделы: ${sections}.
Задавай уточняющие вопросы, если данных недостаточно для действия. Для выполнения действий в программе (список домов, создание счёта, заявки и т.д.) используй только вызов инструментов. Отвечай кратко и по делу на русском.`;
}
/**
* Преобразовать сообщения из БД в формат OpenAI messages[] (role, content; для assistant с tool_calls — role, content, tool_calls).
*/
function dbMessagesToOpenAI(rows) {
const messages = [];
for (const row of rows) {
const msg = { role: row.role, content: row.content || '' };
if (row.toolCallsJson && Array.isArray(row.toolCallsJson) && row.toolCallsJson.length) {
msg.tool_calls = row.toolCallsJson;
}
messages.push(msg);
}
return messages;
}
/**
* Вызвать ИИ (OpenAI-compatible chat completions).
* @param {object} options
* @param {Array} options.messages — OpenAI format
* @param {Array} options.tools — getToolsSchema()
* @returns {Promise<{ content?: string, tool_calls?: Array }>}
*/
async function callChatCompletion({ messages, tools, aiChatUrl, aiApiKey }) {
const url = aiChatUrl != null && aiChatUrl !== '' ? aiChatUrl : AI_CHAT_URL;
const key = aiApiKey !== undefined ? aiApiKey : AI_API_KEY;
const headers = {
'Content-Type': 'application/json'
};
if (key) {
headers['Authorization'] = `Bearer ${key}`;
}
const body = {
model: AI_MODEL,
messages,
tools: tools && tools.length ? tools : undefined,
tool_choice: tools && tools.length ? 'auto' : undefined,
max_tokens: 2048,
temperature: 0.3
};
const res = await axios.post(url, body, {
headers,
timeout: REQUEST_TIMEOUT_MS,
validateStatus: (s) => s === 200
});
const choice = res.data.choices && res.data.choices[0];
if (!choice) {
throw new Error('Пустой ответ от ИИ');
}
const delta = choice.message || {};
return {
content: delta.content || null,
tool_calls: delta.tool_calls || null
};
}
/**
* Обработать один раунд чата: вызов ИИ, при наличии tool_calls — выполнить инструменты и вернуть обновлённые messages + флаг «нужен ещё раунд».
* collectedToolResults — массив, в который пушатся результаты вызовов { toolName, success, error? } для ответа клиенту.
*/
async function oneRound({ messages, tools, user, runToolContext, collectedToolResults, aiChatUrl, aiApiKey }) {
const response = await callChatCompletion({ messages, tools, aiChatUrl, aiApiKey });
const assistantContent = response.content ? response.content.trim() : '';
const toolCalls = response.tool_calls;
const nextMessages = [...messages];
if (toolCalls && toolCalls.length) {
const assistantMsg = {
role: 'assistant',
content: assistantContent || '(вызов инструментов)',
tool_calls: toolCalls
};
nextMessages.push(assistantMsg);
for (const tc of toolCalls) {
const name = tc.function && tc.function.name;
let args = {};
try {
if (tc.function && tc.function.arguments) {
args = typeof tc.function.arguments === 'string' ? JSON.parse(tc.function.arguments) : tc.function.arguments;
}
} catch (e) {
args = {};
}
const result = await runTool(name, args, user, runToolContext);
if (collectedToolResults) {
collectedToolResults.push({ toolName: name, success: result.success, error: result.error });
}
const toolResult = {
role: 'tool',
tool_call_id: tc.id,
content: JSON.stringify(result)
};
nextMessages.push(toolResult);
}
return { messages: nextMessages, done: false, finalContent: null };
}
return { messages: nextMessages, done: true, finalContent: assistantContent };
}
/**
* Получить ответ ИИ по истории и новому сообщению пользователя. Выполняет tool_calls в цикле (до MAX_TOOL_ITERATIONS).
* @param {object} options
* @param {Function} options.query — (sql, params) => Promise<rows>
* @param {number} options.conversationId
* @param {string} options.newUserMessage
* @param {object} options.user — req.user
* @param {object} options.userContext — { userName, role, allowedSections } для системного промпта
* @param {object} options.runToolContext — { baseUrl, apiPrefix, getTokenForUser }
* @param {string} [options.aiChatUrl] — URL ИИ (из панели или env)
* @param {string} [options.aiApiKey] — API key (токен)
* @returns {Promise<{ assistantMessage: string, toolResults: Array }>}
*/
async function getAIResponse({ query, conversationId, newUserMessage, user, userContext, runToolContext, aiChatUrl, aiApiKey }) {
const tools = getToolsSchema();
const systemPrompt = buildSystemPrompt(userContext);
const rows = await query(
`SELECT role, content, tool_calls_json AS "toolCallsJson"
FROM ai_messages
WHERE conversation_id = $1
ORDER BY created_at ASC
LIMIT $2`,
[conversationId, HISTORY_MESSAGES_LIMIT]
);
const history = dbMessagesToOpenAI(rows);
const messages = [
{ role: 'system', content: systemPrompt },
...history,
{ role: 'user', content: newUserMessage }
];
let currentMessages = messages;
let iterations = 0;
let finalContent = '';
const toolResults = [];
while (iterations < MAX_TOOL_ITERATIONS) {
const round = await oneRound({
messages: currentMessages,
tools,
user,
runToolContext,
collectedToolResults: toolResults,
aiChatUrl,
aiApiKey
});
currentMessages = round.messages;
if (round.done) {
finalContent = round.finalContent || '';
break;
}
iterations++;
}
if (!finalContent && iterations >= MAX_TOOL_ITERATIONS) {
finalContent = 'Достигнут лимит шагов. Попробуйте упростить запрос.';
}
return { assistantMessage: finalContent, toolResults };
}
module.exports = {
buildSystemPrompt,
getAIResponse,
callChatCompletion
};

312
backend/aiToolsRegistry.js Executable file
View File

@@ -0,0 +1,312 @@
/**
* Реестр инструментов ИИ-чата: описание для модели (OpenAI tools) и выполнение через self-request с JWT.
*
* КАК ДОБАВИТЬ НОВЫЙ ИНСТРУМЕНТ:
* 1. В TOOLS_FOR_OPENAI — добавить объект { type: 'function', function: { name, description, parameters } }.
* parameters — JSON Schema (properties, required, additionalProperties: false). Описание (description)
* помогает ИИ задавать наводящие вопросы и правильно заполнять аргументы.
* 2. В TOOL_EXECUTORS — добавить запись: method ('GET'|'POST'|'PUT'), pathTemplate (с :param для path),
* опционально queryParams: { argKey: queryKey }, для POST/PUT — bodyMapping: { argKey: destKey } или _createdBy: true.
* 3. runTool подставит path, query string и body, для запросов от имени пользователя вызовет наш API с JWT.
*
* Модули для расширения: финансы (payment-invoices, calendar), заявки (applications), офис (meetings, documents),
* HR (employees, vacations), юр. отдел (contracts, court-cases), развитие (pipeline, OSS).
*/
const axios = require('axios');
/** Текст «что умеет» — возвращается по команде get_capabilities */
const CAPABILITIES_TEXT = `Я помощник в системе управления МКД. Вот что я умею:
**Дома и объекты**
• Показать список всех домов (МКД) — адреса, id, основные данные.
• Показать данные по одному дому по его id (паспорт, лицевые счета и т.д.).
**Участки**
• Показать список участков (районов обслуживания) — название, руководитель.
**Сотрудники**
• Показать список сотрудников (id и имя) — для поиска исполнителя или контакта.
**Финансы — счета на оплату**
• Показать список счетов на оплату (с пагинацией: страница и количество на странице).
• Создать счёт на оплату: контрагент, сумма, назначение (мероприятие/дом/участок/прочее), формат оплаты (наличные/безнал), список услуг (название и сумма). Могу задать уточняющие вопросы, если чего-то не хватает.
**Заявки (диспетчерская)**
• Создать заявку: обязательно адрес и описание; по желанию — заявитель, квартира, срок выполнения.
Просто напиши, что нужно: например «покажи дома», «создай счёт на 10 000 для ООО Рога», «создай заявку по адресу ул. Ленина 5 — течёт кран». Если данных не хватает, я спрошу.`;
/** Описания инструментов в формате OpenAI Chat Completions (tools) */
const TOOLS_FOR_OPENAI = [
{
type: 'function',
function: {
name: 'get_capabilities',
description: 'Вернуть подробное описание всех возможностей помощника в программе МКД. Вызывай, когда пользователь спрашивает «что ты умеешь», «расскажи о возможностях», «какие команды», «помощь», «что можешь» и т.п. Аргументов нет.',
parameters: { type: 'object', properties: {}, additionalProperties: false }
}
},
{
type: 'function',
function: {
name: 'list_buildings',
description: 'Получить список всех домов (МКД). Возвращает массив домов с id, адресом и основными данными.',
parameters: { type: 'object', properties: {}, additionalProperties: false }
}
},
{
type: 'function',
function: {
name: 'get_building',
description: 'Получить данные одного дома по его id (например id из списка домов).',
parameters: {
type: 'object',
properties: {
id: { type: 'string', description: 'Идентификатор дома (id)' }
},
required: ['id'],
additionalProperties: false
}
}
},
{
type: 'function',
function: {
name: 'list_districts',
description: 'Получить список участков (районов обслуживания) с id, названием и руководителем.',
parameters: { type: 'object', properties: {}, additionalProperties: false }
}
},
{
type: 'function',
function: {
name: 'list_employees',
description: 'Получить список сотрудников (id, имя). Используй для выбора исполнителя или поиска по имени.',
parameters: { type: 'object', properties: {}, additionalProperties: false }
}
},
{
type: 'function',
function: {
name: 'list_payment_invoices',
description: 'Получить список счетов на оплату. Можно указать limit и page для пагинации.',
parameters: {
type: 'object',
properties: {
limit: { type: 'number', description: 'Количество записей на странице (по умолчанию 10)' },
page: { type: 'number', description: 'Номер страницы (по умолчанию 1)' }
},
additionalProperties: false
}
}
},
{
type: 'function',
function: {
name: 'create_payment_invoice',
description: 'Создать счёт на оплату. Нужны: контрагент (contractorName), сумма (totalAmount), назначение (purposeType: event, building, district, other), формат оплаты (paymentFormat: cash, non_cash), список услуг (serviceItems: массив объектов { name, amount }). Опционально: purposeDescription, contractorInn, notes.',
parameters: {
type: 'object',
properties: {
contractorName: { type: 'string', description: 'Название контрагента (ИП/ООО)' },
totalAmount: { type: 'number', description: 'Общая сумма в рублях' },
purposeType: { type: 'string', enum: ['event', 'building', 'district', 'other'], description: 'Тип назначения платежа' },
paymentFormat: { type: 'string', enum: ['cash', 'non_cash'], description: 'Формат оплаты' },
serviceItems: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string' },
amount: { type: 'number' }
},
required: ['name', 'amount']
},
description: 'Список услуг: каждый элемент { name, amount }'
},
purposeDescription: { type: 'string', description: 'Текстовое описание назначения' },
contractorInn: { type: 'string', description: 'ИНН контрагента' },
notes: { type: 'string', description: 'Заметки' }
},
required: ['contractorName', 'totalAmount', 'purposeType', 'paymentFormat', 'serviceItems'],
additionalProperties: false
}
}
},
{
type: 'function',
function: {
name: 'create_application',
description: 'Создать заявку (диспетчерская). Обязательно: адрес (address), описание (description). Опционально: contactName, apartment, deadlineAt (дата в ISO).',
parameters: {
type: 'object',
properties: {
address: { type: 'string', description: 'Адрес объекта' },
description: { type: 'string', description: 'Описание заявки' },
contactName: { type: 'string', description: 'ФИО заявителя' },
apartment: { type: 'string', description: 'Квартира/помещение' },
deadlineAt: { type: 'string', description: 'Срок выполнения (ISO дата)' }
},
required: ['address', 'description'],
additionalProperties: false
}
}
}
];
/**
* Исполнение инструмента: method, path (шаблон с :param), опционально queryParams, bodyMapping.
* queryParams: объект { argKey: queryKey } — добавляет query string из args.
* bodyMapping: для POST/PUT — подстановка body; _createdBy: true — подставить user.userId в createdBy.
*/
const TOOL_EXECUTORS = {
list_buildings: {
method: 'GET',
pathTemplate: '/buildings',
queryParams: null,
bodyMapping: null
},
get_building: {
method: 'GET',
pathTemplate: '/buildings/:id',
queryParams: null,
bodyMapping: null
},
list_districts: {
method: 'GET',
pathTemplate: '/districts',
queryParams: null,
bodyMapping: null
},
list_employees: {
method: 'GET',
pathTemplate: '/employees/list',
queryParams: null,
bodyMapping: null
},
list_payment_invoices: {
method: 'GET',
pathTemplate: '/finance/payment-invoices',
queryParams: { limit: 'limit', page: 'page' },
bodyMapping: null
},
create_payment_invoice: {
method: 'POST',
pathTemplate: '/finance/payment-invoices',
queryParams: null,
bodyMapping: {
_createdBy: true,
contractorName: 'contractorName',
totalAmount: 'totalAmount',
purposeType: 'purposeType',
paymentFormat: 'paymentFormat',
serviceItems: 'serviceItems',
purposeDescription: 'purposeDescription',
contractorInn: 'contractorInn',
notes: 'notes'
}
},
create_application: {
method: 'POST',
pathTemplate: '/applications',
queryParams: null,
bodyMapping: {
address: 'address',
description: 'description',
contactName: 'contactName',
apartment: 'apartment',
deadlineAt: 'deadlineAt'
}
}
};
/**
* Подставить параметры в path (например :id -> args.id)
*/
function buildPath(pathTemplate, args) {
let p = pathTemplate;
const re = /:(\w+)/g;
let m;
while ((m = re.exec(pathTemplate)) !== null) {
const key = m[1];
const val = args && args[key];
if (val === undefined || val === null) {
throw new Error(`Параметр "${key}" обязателен для пути ${pathTemplate}`);
}
p = p.replace(new RegExp(':' + key + '(?=/|$)'), encodeURIComponent(String(val)));
}
return p;
}
/**
* Выполнить инструмент от имени пользователя: внутренний HTTP-запрос к нашему API с JWT.
* @param {string} toolName
* @param {object} args — аргументы от ИИ
* @param {object} user — { userId, employeeId, role } (req.user)
* @param {object} context — { baseUrl, apiPrefix, getTokenForUser }
* @returns {Promise<{ success: boolean, data?: any, error?: string }>}
*/
async function runTool(toolName, args, user, context) {
if (toolName === 'get_capabilities') {
return { success: true, data: { text: CAPABILITIES_TEXT } };
}
const { baseUrl, apiPrefix, getTokenForUser } = context;
const executor = TOOL_EXECUTORS[toolName];
if (!executor) {
return { success: false, error: `Неизвестный инструмент: ${toolName}` };
}
const path = buildPath(executor.pathTemplate, args || {});
let url = `${baseUrl}${apiPrefix}${path}`;
if (executor.queryParams && args) {
const q = [];
for (const [argKey, queryKey] of Object.entries(executor.queryParams)) {
if (args[argKey] !== undefined && args[argKey] !== null) {
q.push(`${encodeURIComponent(queryKey)}=${encodeURIComponent(String(args[argKey]))}`);
}
}
if (q.length) url += '?' + q.join('&');
}
const token = getTokenForUser(user);
const headers = {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
};
let body = undefined;
if (executor.bodyMapping && (executor.method === 'POST' || executor.method === 'PUT')) {
body = {};
for (const [argKey, destKey] of Object.entries(executor.bodyMapping)) {
if (argKey === '_createdBy' && destKey === true) {
body.createdBy = user.userId;
continue;
}
if (args && argKey in args) {
body[destKey] = args[argKey];
}
}
}
try {
const res = await axios({
method: executor.method,
url,
headers,
data: body,
timeout: 30000,
validateStatus: () => true
});
if (res.status >= 200 && res.status < 300) {
return { success: true, data: res.data };
}
const errText = res.data && (res.data.error || res.data.message) ? String(res.data.error || res.data.message) : res.statusText;
return { success: false, error: `HTTP ${res.status}: ${errText}` };
} catch (err) {
const msg = err.response ? `HTTP ${err.response.status}` : err.message || 'Ошибка запроса';
return { success: false, error: msg };
}
}
module.exports = {
getToolsSchema: () => TOOLS_FOR_OPENAI,
runTool,
TOOL_EXECUTORS
};

View File

@@ -0,0 +1,157 @@
const csv = require('csv-parser');
const XLSX = require('xlsx');
const fs = require('fs');
/**
* Процессор для обработки ОСВ по счёту 76.06 (лицевые счета жителей)
*/
class BalanceSheet76Processor {
constructor() {}
parseAmount(value) {
if (!value) return 0;
const str = String(value).replace(/\s/g, '').replace(',', '.');
const num = parseFloat(str);
return isNaN(num) ? 0 : num;
}
_isDataRow(rowArray) {
if (!rowArray || rowArray.length < 5) return false;
const firstCell = String(rowArray[0] || '').trim();
const col1 = this.parseAmount(rowArray[1]);
const col3 = this.parseAmount(rowArray[3]);
const col4 = this.parseAmount(rowArray[4]);
return firstCell.length >= 1 && (col1 > 0 || col3 > 0 || col4 > 0);
}
/**
* Извлечь номер л/с из колонки 0 (например "00-000000001, ФИО (л/с)" -> "00-000000001")
*/
extractAccountLs(firstCell) {
const s = String(firstCell || '').trim();
const comma = s.indexOf(',');
if (comma > 0) {
const prefix = s.substring(0, comma).trim();
if (/^[\d\-]+$/.test(prefix) || /^[\d\-]+\s*$/.test(prefix)) return prefix;
}
return null;
}
/**
* Парсинг CSV ОСВ 76 (UTF-8, BOM, автоопределение начала данных)
*/
async parseBalanceSheet76CSV(filePath) {
return new Promise((resolve, reject) => {
const allRows = [];
let currentRowIndex = 0;
let dataStartRowIndex = -1;
fs.createReadStream(filePath, { encoding: 'utf8' })
.pipe(csv({ separator: ';', headers: false }))
.on('data', (row) => {
const rowArray = Object.values(row);
if (currentRowIndex === 0 && rowArray[0] !== undefined) {
rowArray[0] = String(rowArray[0]).replace(/^\uFEFF/, '');
}
allRows.push({ index: currentRowIndex, data: rowArray });
currentRowIndex++;
})
.on('end', () => {
for (let i = 0; i < allRows.length; i++) {
const rowArray = allRows[i].data;
const firstCell = String(rowArray[0] || '').trim();
if (firstCell.includes('Обороты за период') || firstCell.includes('Контрагенты') || firstCell.includes('Счет,')) {
dataStartRowIndex = i + 1;
break;
}
if (this._isDataRow(rowArray)) {
dataStartRowIndex = i;
break;
}
}
if (dataStartRowIndex < 0) dataStartRowIndex = 9;
const rows = allRows.filter(r => r.index >= dataStartRowIndex);
resolve(rows);
})
.on('error', reject);
});
}
async parseBalanceSheet76XLSX(filePath) {
const workbook = XLSX.readFile(filePath);
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const rawData = XLSX.utils.sheet_to_json(worksheet, { header: 1, defval: '', raw: false });
const rows = [];
for (let i = 9; i < rawData.length; i++) {
rows.push({ index: i, data: rawData[i] || [] });
}
return rows;
}
/**
* Парсинг ведомости 76 и возврат массива строк по лицевым счетам
*/
async parseBalanceSheet76(filePath, fileType) {
const rows = fileType === 'CSV'
? await this.parseBalanceSheet76CSV(filePath)
: await this.parseBalanceSheet76XLSX(filePath);
const result = [];
let rowIndex = 0;
for (const row of rows) {
const rowData = row.data;
if (!rowData || rowData.length === 0) continue;
const firstCell = String(rowData[0] || '').trim();
if (!firstCell) continue;
if (firstCell === '76.06' || firstCell === 'Итого') continue;
const saldoStartDebet = this.parseAmount(rowData[1]);
const turnoverDebet = this.parseAmount(rowData[3]);
const turnoverCredit = this.parseAmount(rowData[4]);
const saldoEndDebet = this.parseAmount(rowData[5]);
const saldoEndCredit = this.parseAmount(rowData[6]);
result.push({
row_index: rowIndex,
account_label: firstCell,
account_ls: this.extractAccountLs(firstCell),
saldo_start_debet: saldoStartDebet,
turnover_debet: turnoverDebet,
turnover_credit: turnoverCredit,
saldo_end_debet: saldoEndDebet,
saldo_end_credit: saldoEndCredit
});
rowIndex++;
}
console.log(`[BalanceSheet76Processor] Распознано строк (лицевых счетов): ${result.length}`);
return result;
}
extractPeriodFromFilename(filename) {
const yearMatch = filename.match(/20\d{2}/);
const year = yearMatch ? parseInt(yearMatch[0]) : new Date().getFullYear();
const monthMatch = filename.match(/(\d{1,2})[\.\/](\d{4})|(\d{4})[\.\/](\d{1,2})|Январь|январь/i);
if (monthMatch) {
const monthNames = { январ: 1, феврал: 2, март: 3, апрел: 4, май: 5, июн: 6, июл: 7, август: 8, сентябр: 9, октябр: 10, ноябр: 11, декабр: 12 };
const lower = filename.toLowerCase();
for (const [key, m] of Object.entries(monthNames)) {
if (lower.includes(key)) {
const startDate = new Date(year, m - 1, 1);
const endDate = new Date(year, m, 0);
return {
start: `${year}-${String(m).padStart(2, '0')}-01`,
end: `${year}-${String(m).padStart(2, '0')}-${String(endDate.getDate()).padStart(2, '0')}`,
type: 'month'
};
}
}
}
return { start: `${year}-01-01`, end: `${year}-12-31`, type: 'year' };
}
}
module.exports = BalanceSheet76Processor;

487
backend/balanceSheetProcessor.js Executable file
View File

@@ -0,0 +1,487 @@
const csv = require('csv-parser');
const XLSX = require('xlsx');
const fs = require('fs');
/**
* Процессор для обработки оборотно-сальдовой ведомости из 1С
*/
class BalanceSheetProcessor {
constructor(pool) {
this.pool = pool;
}
/**
* Проверка, что строка похожа на данные (в колонке 0 текст, в 3 или 4 — число)
*/
_isDataRow(rowArray) {
if (!rowArray || rowArray.length < 5) return false;
const firstCell = String(rowArray[0] || '').trim();
const col3 = this.parseAmount(rowArray[3]);
const col4 = this.parseAmount(rowArray[4]);
return firstCell.length >= 1 && (col3 > 0 || col4 > 0);
}
/**
* Парсинг CSV файла ведомости (UTF-8, с автоопределением начала данных и обработкой BOM)
*/
async parseBalanceSheetCSV(filePath) {
return new Promise((resolve, reject) => {
const allRows = [];
let currentRowIndex = 0;
let dataStartRowIndex = -1;
fs.createReadStream(filePath, { encoding: 'utf8' })
.pipe(csv({ separator: ';', headers: false }))
.on('data', (row) => {
const rowArray = Object.values(row);
// Убираем BOM с первой ячейки первой строки
if (currentRowIndex === 0 && rowArray[0] !== undefined) {
const first = String(rowArray[0]).replace(/^\uFEFF/, '');
rowArray[0] = first;
}
allRows.push({ index: currentRowIndex, data: rowArray });
currentRowIndex++;
})
.on('end', () => {
// Автоопределение строки начала данных: ищем первую строку, где колонка 0 — текст, колонка 3 или 4 — число
for (let i = 0; i < allRows.length; i++) {
const rowArray = allRows[i].data;
const firstCell = String(rowArray[0] || '').trim();
if (firstCell.includes('Обороты за период') || firstCell.includes('Счет, Наименование')) {
dataStartRowIndex = i + 1;
break;
}
if (this._isDataRow(rowArray)) {
dataStartRowIndex = i;
break;
}
}
if (dataStartRowIndex < 0) dataStartRowIndex = 9;
const rows = allRows.filter(r => r.index >= dataStartRowIndex);
console.log(`[BalanceSheetProcessor] Начало данных с строки ${dataStartRowIndex}, строк данных: ${rows.length}`);
resolve(rows);
})
.on('error', reject);
});
}
/**
* Парсинг XLSX файла ведомости
*/
async parseBalanceSheetXLSX(filePath) {
try {
const workbook = XLSX.readFile(filePath);
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
// Конвертируем в массив массивов
const rawData = XLSX.utils.sheet_to_json(worksheet, {
header: 1,
defval: '',
raw: false
});
// Пропускаем метаданные (первые 9 строк)
const rows = [];
for (let i = 9; i < rawData.length; i++) {
rows.push({
index: i,
data: rawData[i] || []
});
}
return rows;
} catch (error) {
throw new Error(`Ошибка чтения XLSX файла: ${error.message}`);
}
}
/**
* Определение адреса дома из строки
*/
isBuildingAddress(rowData) {
if (!rowData || rowData.length === 0) return false;
const firstCell = String(rowData[0] || '').trim();
// Пропускаем служебные строки
if (firstCell.startsWith('<...>') ||
firstCell === '' ||
firstCell.includes('Оборотно-сальдовая') ||
firstCell.includes('Выводимые данные') ||
firstCell.includes('Счет, Наименование') ||
firstCell.includes('Подразделение') ||
firstCell.includes('Номенклатурные группы') ||
firstCell.includes('Статьи затрат')) {
return false;
}
// Не является счетом (20, 20.01, 20.01.01)
if (/^\d+\.?\d*\.?\d*$/.test(firstCell)) return false;
// Не является статьей затрат (содержит типичные слова статей без адреса)
const expenseKeywords = [
'Административно-управленческие расходы',
'Текущее обслуживание общедомового имущества',
'Ремонт кровли',
'Благоустройство территории',
'Услуги специализированных организаций',
'Техническое обслуживание',
'Расходные материалы',
'Механизированная уборка',
'Хозяйственные расходы',
'Резервный фонд',
'Судебные издержки',
'Обслуживание инженерного оборудования'
];
// Если строка содержит только ключевые слова статей без адреса - это не адрес
const isOnlyExpenseKeyword = expenseKeywords.some(keyword =>
firstCell === keyword || firstCell.startsWith(keyword + ';')
);
if (isOnlyExpenseKeyword) return false;
// Адрес должен содержать буквы (название улицы/участка)
const hasLetters = /[А-Яа-я]/.test(firstCell);
// Проверяем наличие сумм в строке (колонки 3-6: обороты и сальдо)
// Если есть большие суммы - это может быть адрес участка/комплекса
const hasAmounts = rowData.length > 3 && (
this.parseAmount(rowData[3]) > 0 || // Дебет обороты
this.parseAmount(rowData[4]) > 0 || // Кредит обороты
this.parseAmount(rowData[5]) > 0 || // Дебет сальдо
this.parseAmount(rowData[6]) > 0 // Кредит сальдо
);
// Адрес с номером дома (стандартный случай)
const hasNumber = /\d/.test(firstCell);
// Паттерны адресов
const addressPatterns = [
/\d+\/\d+/, // "6/1", "152/4" - номер дома с корпусом
/^[А-Яа-я]+\s+[А-Яа-я]+.*\d/, // "Булата Имашева 6/1"
/ул\.|пер\.|пр\.|проспект.*\d/i, // "ул. Ленина, д.12"
/д\.\s*\d/i, // "д. 12"
/^[А-Яа-я]+.*\d+[\/\-]?\d*/, // Общий паттерн: буквы + цифры
/^[А-Яа-я]+\s+[А-Яа-я]+$/ // Название участка/комплекса без номера (например, "Зеленая Роща")
];
const hasAddressPattern = addressPatterns.some(pattern => pattern.test(firstCell));
// Адрес обычно не слишком длинный (до 100 символов) и не слишком короткий (больше 3)
const validLength = firstCell.length >= 4 && firstCell.length < 100;
// Если есть паттерн адреса, буквы и валидная длина - это адрес
// Дополнительно: если есть суммы в строке и это похоже на название участка (2 слова без цифр) - тоже адрес
const isDistrictName = /^[А-Яа-я]+\s+[А-Яа-я]+$/.test(firstCell) && hasAmounts;
return hasAddressPattern && hasLetters && validLength && (hasNumber || isDistrictName);
}
/**
* Парсинг ведомости и группировка по домам
*/
async parseBalanceSheet(filePath, fileType) {
let rows;
if (fileType === 'CSV') {
rows = await this.parseBalanceSheetCSV(filePath);
} else if (fileType === 'XLSX') {
rows = await this.parseBalanceSheetXLSX(filePath);
} else {
throw new Error(`Неподдерживаемый тип файла: ${fileType}`);
}
const buildings = {};
let currentBuilding = null;
let currentGroup = null;
let currentArticle = null;
console.log(`[BalanceSheetProcessor] Начало парсинга, строк в файле: ${rows.length}`);
for (const row of rows) {
const rowData = row.data;
if (!rowData || rowData.length === 0) continue;
const firstCell = String(rowData[0] || '').trim();
const debitTurnover = this.parseAmount(rowData[3]); // Обороты за период - Дебет
const creditTurnover = this.parseAmount(rowData[4]); // Обороты за период - Кредит
const endDebit = this.parseAmount(rowData[5]); // Сальдо на конец периода - Дебет
const endCredit = this.parseAmount(rowData[6]); // Сальдо на конец периода - Кредит
// Пропускаем пустые строки
if (!firstCell || firstCell === '') continue;
// Определяем адрес дома
if (this.isBuildingAddress(rowData)) {
// Если это тот же адрес, что и текущий - не сбрасываем группу (может быть повтор)
if (currentBuilding !== firstCell) {
currentBuilding = firstCell;
currentGroup = null;
currentArticle = null;
console.log(`[BalanceSheetProcessor] Найден адрес дома: "${currentBuilding}"`);
}
if (!buildings[currentBuilding]) {
buildings[currentBuilding] = {
address: currentBuilding,
groups: {},
totalExpenses: 0,
totalIncome: 0,
balance: 0
};
}
// Учитываем сумму в строке адреса, чтобы расход по дому не был нулевым при отсутствии статей ниже
if (debitTurnover > 0) {
buildings[currentBuilding].totalExpenses += debitTurnover;
if (!buildings[currentBuilding].groups['Итого по дому']) {
buildings[currentBuilding].groups['Итого по дому'] = {
name: 'Итого по дому',
articles: {},
total: 0
};
buildings[currentBuilding].groups['Итого по дому'].articles[currentBuilding] = {
name: currentBuilding,
amount: 0,
details: []
};
}
buildings[currentBuilding].groups['Итого по дому'].articles[currentBuilding].amount += debitTurnover;
buildings[currentBuilding].groups['Итого по дому'].total += debitTurnover;
}
continue;
}
// Если дом не определен, пропускаем строку
if (!currentBuilding) continue;
// Определяем номенклатурную группу: известные названия (точное/частичное) или произвольное название из ОСВ
// Список типичных групп — у разных УК формулировки могут отличаться, поэтому проверяем и по вхождению
const groupKeywords = [
'Административно-управленческие расходы',
'Судебные издержки',
'Услуги специализированных организаций',
'Благоустройство территории',
'Техническое обслуживание конструктивных элементов',
'Обслуживание инженерного оборудования',
'Расходные материалы',
'Текущее обслуживание общедомового имущества',
'Механизированная уборка',
'Хозяйственные расходы',
'Резервный фонд'
];
const firstCellLower = firstCell.toLowerCase();
const matchedGroup = groupKeywords.find(keyword =>
firstCell === keyword ||
firstCell.startsWith(keyword + ';') ||
firstCell.startsWith(keyword) ||
firstCellLower.includes(keyword.toLowerCase())
);
if (matchedGroup) {
currentGroup = matchedGroup;
currentArticle = null;
if (!buildings[currentBuilding].groups[currentGroup]) {
buildings[currentBuilding].groups[currentGroup] = {
name: currentGroup,
articles: {},
total: 0
};
}
continue;
}
// Динамическая группа: если есть сумма и строка не служебная — считаем название группы/услуги из ОСВ (у разных компаний названия разные)
if (currentBuilding && debitTurnover > 0 && !firstCell.startsWith('<...>') && firstCell.length >= 2) {
const dynamicGroupName = firstCell.trim();
if (!buildings[currentBuilding].groups[dynamicGroupName]) {
buildings[currentBuilding].groups[dynamicGroupName] = {
name: dynamicGroupName,
articles: {},
total: 0
};
}
currentGroup = dynamicGroupName;
currentArticle = firstCell;
if (!buildings[currentBuilding].groups[currentGroup].articles[currentArticle]) {
buildings[currentBuilding].groups[currentGroup].articles[currentArticle] = {
name: currentArticle,
amount: 0,
details: []
};
}
buildings[currentBuilding].groups[currentGroup].articles[currentArticle].amount += debitTurnover;
buildings[currentBuilding].groups[currentGroup].total += debitTurnover;
buildings[currentBuilding].totalExpenses += debitTurnover;
if (rowData.length > 1) {
const secondCell = String(rowData[1] || '').trim();
if (secondCell && secondCell !== '' && !secondCell.match(/^\d+[\s,\.]*\d*$/)) {
buildings[currentBuilding].groups[currentGroup].articles[currentArticle].details.push({
description: secondCell,
amount: debitTurnover
});
}
}
continue;
}
// Определяем статью затрат (если есть отступ или вложенность)
if (currentGroup && debitTurnover > 0) {
// Пропускаем строки с "<...>" - это служебные строки
if (firstCell.startsWith('<...>')) {
continue;
}
// Это статья затрат
if (!currentArticle || firstCell !== currentArticle) {
currentArticle = firstCell;
if (!buildings[currentBuilding].groups[currentGroup].articles[currentArticle]) {
buildings[currentBuilding].groups[currentGroup].articles[currentArticle] = {
name: currentArticle,
amount: 0,
details: []
};
}
}
// Добавляем сумму
buildings[currentBuilding].groups[currentGroup].articles[currentArticle].amount += debitTurnover;
buildings[currentBuilding].groups[currentGroup].total += debitTurnover;
buildings[currentBuilding].totalExpenses += debitTurnover;
// Сохраняем детализацию если есть (вторая колонка может содержать описание)
if (rowData.length > 1) {
const secondCell = String(rowData[1] || '').trim();
if (secondCell && secondCell !== '' && !secondCell.match(/^\d+[\s,\.]*\d*$/)) {
// Это не число, значит описание
buildings[currentBuilding].groups[currentGroup].articles[currentArticle].details.push({
description: secondCell,
amount: debitTurnover
});
}
}
}
}
const buildingCount = Object.keys(buildings).length;
console.log(`[BalanceSheetProcessor] Найдено домов: ${buildingCount}`);
if (buildingCount > 0) {
console.log(`[BalanceSheetProcessor] Адреса домов:`, Object.keys(buildings).slice(0, 5).join(', '), buildingCount > 5 ? '...' : '');
for (const [addr, data] of Object.entries(buildings)) {
const articleCount = Object.values(data.groups || {}).reduce((s, g) => s + Object.keys(g.articles || {}).length, 0);
console.log(`[BalanceSheetProcessor] Дом "${addr}": totalExpenses=${data.totalExpenses}, статей в expenses_by_items: ${articleCount}`);
}
} else {
console.warn(`[BalanceSheetProcessor] ⚠ Дома не найдены в ведомости!`);
}
return buildings;
}
/**
* Парсинг суммы из строки (удаление пробелов, запятых)
*/
parseAmount(value) {
if (!value) return 0;
const str = String(value).replace(/\s/g, '').replace(',', '.');
const num = parseFloat(str);
return isNaN(num) ? 0 : num;
}
/**
* Преобразование данных ведомости в формат для БД
*/
convertToBuildingFinancialData(buildings, reportId, periodStart, periodEnd) {
const result = [];
for (const [address, buildingData] of Object.entries(buildings)) {
// Формируем expenses_by_items
const expensesByItems = {};
for (const [groupName, group] of Object.entries(buildingData.groups)) {
for (const [articleName, article] of Object.entries(group.articles)) {
const key = `${groupName} > ${articleName}`;
expensesByItems[key] = article.amount;
}
}
result.push({
address: address,
totalIncome: buildingData.totalIncome || 0,
totalExpenses: buildingData.totalExpenses || 0,
balance: buildingData.balance || (buildingData.totalIncome - buildingData.totalExpenses),
expensesByItems: expensesByItems,
periodStart: periodStart,
periodEnd: periodEnd,
periodType: this.determinePeriodType(periodStart, periodEnd),
metadata: {
groups: buildingData.groups,
rawData: buildingData
}
});
}
return result;
}
/**
* Определение типа периода
*/
determinePeriodType(start, end) {
const startDate = new Date(start);
const endDate = new Date(end);
const diffMonths = (endDate.getFullYear() - startDate.getFullYear()) * 12 +
(endDate.getMonth() - startDate.getMonth());
if (diffMonths === 0) return 'month';
if (diffMonths <= 3) return 'quarter';
if (diffMonths <= 12) return 'year';
return 'year';
}
/**
* Извлечение периода из названия файла или метаданных
*/
extractPeriodFromFilename(filename) {
// Пытаемся найти год в названии файла
const yearMatch = filename.match(/20\d{2}/);
const year = yearMatch ? parseInt(yearMatch[0]) : new Date().getFullYear();
// Пытаемся найти месяц в названии файла (например, "за_2025_г" или "01.2025")
const monthMatch = filename.match(/(\d{1,2})[\.\/](\d{4})|(\d{4})[\.\/](\d{1,2})/);
if (monthMatch) {
// Найден месяц и год
let month, yearFromMatch;
if (monthMatch[1] && monthMatch[2]) {
month = parseInt(monthMatch[1]);
yearFromMatch = parseInt(monthMatch[2]);
} else if (monthMatch[3] && monthMatch[4]) {
yearFromMatch = parseInt(monthMatch[3]);
month = parseInt(monthMatch[4]);
}
if (month && yearFromMatch) {
const startDate = new Date(yearFromMatch, month - 1, 1);
const endDate = new Date(yearFromMatch, month, 0); // Последний день месяца
return {
start: `${yearFromMatch}-${String(month).padStart(2, '0')}-01`,
end: `${yearFromMatch}-${String(month).padStart(2, '0')}-${String(endDate.getDate()).padStart(2, '0')}`,
type: 'month'
};
}
}
// Если месяц не найден, используем весь год
return {
start: `${year}-01-01`,
end: `${year}-12-31`,
type: 'year'
};
}
}
module.exports = BalanceSheetProcessor;

54
backend/constants/roleAccess.js Executable file
View File

@@ -0,0 +1,54 @@
/**
* Единый источник прав портала (синхронизирован с constants/roleAccess.ts).
* Роли, разделы по умолчанию, scope по умолчанию.
*/
const ROLE_ACCESS = {
DIRECTOR: ['all'],
ENGINEER: ['dashboard', 'objects', 'requests', 'office', 'development'],
MASTER: ['objects', 'requests'],
LAWYER: ['dashboard', 'legal', 'objects', 'requests'],
FINANCIER: ['dashboard', 'finance', 'office', 'objects'],
HR_MANAGER: ['dashboard', 'hr', 'office'],
PR_MANAGER: ['dashboard', 'pr', 'requests'],
};
const ROLE_NAMES = {
DIRECTOR: 'Директор',
ENGINEER: 'Гл. Инженер',
MASTER: 'Мастер',
LAWYER: 'Юрист',
FINANCIER: 'Финансист',
HR_MANAGER: 'HR-менеджер',
PR_MANAGER: 'PR-менеджер',
};
const ROLE_DEFAULT_SCOPE = {
DIRECTOR: 'all',
ENGINEER: 'all',
MASTER: 'own_district',
LAWYER: 'all',
FINANCIER: 'all',
HR_MANAGER: 'all',
PR_MANAGER: 'all',
};
const SECTION_IDS = [
'dashboard',
'objects',
'requests',
'pr',
'finance',
'legal',
'development',
'hr',
'office',
'admin',
];
module.exports = {
ROLE_ACCESS,
ROLE_NAMES,
ROLE_DEFAULT_SCOPE,
SECTION_IDS,
};

View File

@@ -0,0 +1,33 @@
-- Скрипт для создания таблицы vacancies и связанных объектов
-- Выполните этот скрипт, если таблица vacancies отсутствует в базе данных
-- Создание типа vacancy_status (если не существует)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'vacancy_status') THEN
CREATE TYPE vacancy_status AS ENUM ('urgent', 'active', 'paused', 'closed');
END IF;
END$$;
-- Создание таблицы vacancies
CREATE TABLE IF NOT EXISTS vacancies (
id VARCHAR(50) PRIMARY KEY,
position TEXT NOT NULL, -- Название должности
department TEXT NOT NULL, -- Отдел
status vacancy_status NOT NULL DEFAULT 'active',
salary TEXT, -- Вилка зарплаты (например, "55 000 - 65 000 ₽")
description TEXT NOT NULL, -- Описание вакансии
requirements TEXT, -- Требования к кандидату
conditions TEXT, -- Условия работы
responsibilities TEXT, -- Обязанности
posted_date DATE NOT NULL DEFAULT CURRENT_DATE, -- Дата публикации
closing_date DATE, -- Дата закрытия вакансии
applicants_count INTEGER DEFAULT 0, -- Количество откликов
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Создание индексов
CREATE INDEX IF NOT EXISTS idx_vacancies_status ON vacancies(status);
CREATE INDEX IF NOT EXISTS idx_vacancies_department ON vacancies(department);
CREATE INDEX IF NOT EXISTS idx_vacancies_posted_date ON vacancies(posted_date DESC);

3607
backend/dbInit.js Executable file

File diff suppressed because it is too large Load Diff

115
backend/debtorReportProcessor.js Executable file
View File

@@ -0,0 +1,115 @@
const csv = require('csv-parser');
const fs = require('fs');
/**
* Парсер отчёта по задолженности (CSV с разделителем ;).
* Строки данных: № п/п (0), Лицевой счёт (1), Ответственный (4), Объект учета (5), Количество месяцев (8), последняя колонка — Общая задолженность.
*/
class DebtorReportProcessor {
/**
* Нормализация числа из отчёта: пробелы убрать, запятую заменить на точку.
*/
parseAmount(str) {
if (str === undefined || str === null || str === '') return null;
const s = String(str).replace(/\s/g, '').replace(',', '.').trim();
if (s === '' || s === '-') return null;
const n = parseFloat(s);
return Number.isFinite(n) ? n : null;
}
/**
* Проверка: строка похожа на строку данных (колонка 0 — число, колонка 1 — лицевой счёт вида 00-000000001).
*/
isDataRow(cells) {
if (!cells || cells.length < 6) return false;
const col0 = String(cells[0] || '').trim();
const col1 = String(cells[1] || '').trim();
if (col1 === '' || col1.toLowerCase() === 'итого') return false;
const num0 = parseInt(col0, 10);
const isNum = col0 !== '' && !Number.isNaN(num0) && num0 >= 1;
const looksLikeAccount = /^[\d\-]+$/.test(col1) || /^\d{2}-\d{9}$/.test(col1.replace(/\s/g, ''));
return isNum && (looksLikeAccount || col1.length >= 5);
}
/**
* Парсинг одной строки данных в объект для БД.
*/
parseDataRow(cells, rowIndex) {
const account = String(cells[1] || '').trim();
const responsibleName = String(cells[4] || '').trim();
const objectAddress = String(cells[5] || '').trim();
const monthsRaw = String(cells[8] || '').trim();
const monthsDebt = monthsRaw === '' ? null : (parseInt(monthsRaw, 10) || null);
const lastCol = cells[cells.length - 1];
const totalDebt = this.parseAmount(lastCol);
return {
row_index: rowIndex,
account,
responsible_name: responsibleName,
object_address: objectAddress,
months_debt: monthsDebt,
total_debt: totalDebt != null ? totalDebt : 0,
};
}
/**
* Парсинг CSV-файла отчёта по задолженности.
* Возвращает массив объектов { row_index, account, responsible_name, object_address, months_debt, total_debt }.
*/
async parseDebtorReportCSV(filePath) {
return new Promise((resolve, reject) => {
const rows = [];
let rowIndex = 0;
fs.createReadStream(filePath, { encoding: 'utf8' })
.pipe(csv({ separator: ';', headers: false }))
.on('data', (row) => {
const cells = Object.values(row);
if (rowIndex === 0 && cells[0] !== undefined) {
const first = String(cells[0]).replace(/^\uFEFF/, '');
cells[0] = first;
}
if (this.isDataRow(cells)) {
rows.push(this.parseDataRow(cells, rowIndex));
}
rowIndex++;
})
.on('end', () => resolve(rows))
.on('error', reject);
});
}
/**
* Парсинг XLSX: чтение первого листа, поиск строк данных по тем же правилам.
*/
async parseDebtorReportXLSX(filePath) {
const XLSX = require('xlsx');
const workbook = XLSX.readFile(filePath);
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const rawData = XLSX.utils.sheet_to_json(worksheet, { header: 1, defval: '' });
const rows = [];
for (let i = 0; i < rawData.length; i++) {
const cells = rawData[i] || [];
if (this.isDataRow(cells)) {
rows.push(this.parseDataRow(cells, i));
}
}
return rows;
}
/**
* Единая точка входа: парсинг по типу файла.
*/
async parseDebtorReport(filePath, fileType) {
if (fileType === 'CSV') {
return this.parseDebtorReportCSV(filePath);
}
if (fileType === 'XLSX') {
return this.parseDebtorReportXLSX(filePath);
}
throw new Error('Поддерживаются только CSV и XLSX');
}
}
module.exports = DebtorReportProcessor;

969
backend/fileProcessor.js Executable file
View File

@@ -0,0 +1,969 @@
const csv = require('csv-parser');
const XLSX = require('xlsx');
const fs = require('fs');
const { Pool } = require('pg');
/**
* Обработчик файлов CSV/XLSX для загрузки финансовых данных из 1С
*/
class FileProcessor {
constructor(pool) {
this.pool = pool;
this.processingJobs = new Map(); // Хранилище статусов обработки
}
/**
* Обработка файла (CSV или XLSX)
*/
processFile(filePath, fileType, mapping, reportId) {
const jobId = `job-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// Инициализируем статус обработки
this.processingJobs.set(jobId, {
status: 'processing',
progress: 0,
currentStep: 'Начало обработки',
errors: [],
warnings: [],
result: null
});
// Запускаем асинхронную обработку (не ждем завершения)
const processPromise = this._processFileInternal(filePath, fileType, mapping, reportId, jobId);
// Возвращаем jobId и Promise сразу
return { jobId, processPromise };
}
/**
* Внутренняя обработка файла
*/
async _processFileInternal(filePath, fileType, mapping, reportId, jobId) {
try {
let rows = [];
if (fileType === 'CSV') {
rows = await this.parseCSV(filePath, jobId);
} else if (fileType === 'XLSX') {
rows = await this.parseXLSX(filePath, jobId);
} else {
throw new Error(`Неподдерживаемый тип файла: ${fileType}`);
}
// Валидация структуры файла
this.updateJobStatus(jobId, {
progress: 20,
currentStep: 'Валидация структуры файла'
});
const validationResult = this.validateFileStructure(rows, mapping);
// Добавляем предупреждения из валидации
if (validationResult.warnings && validationResult.warnings.length > 0) {
const current = this.processingJobs.get(jobId);
if (current) {
if (!current.warnings) {
current.warnings = [];
}
current.warnings.push(...validationResult.warnings);
this.processingJobs.set(jobId, current);
}
}
if (!validationResult.valid) {
this.updateJobStatus(jobId, {
status: 'failed',
progress: 100,
errors: validationResult.errors
});
return { jobId, success: false, errors: validationResult.errors };
}
// Обновляем маппинг с найденными колонками
if (validationResult.foundColumns && validationResult.foundColumns.length > 0) {
const updatedMapping = { ...mapping };
for (const { required, found } of validationResult.foundColumns) {
if (updatedMapping.columnMappings[required]) {
updatedMapping.columnMappings[found] = updatedMapping.columnMappings[required];
// Если названия отличаются, удаляем старое
if (required !== found) {
delete updatedMapping.columnMappings[required];
}
}
}
mapping = updatedMapping;
}
// Применение маппинга и преобразование данных
this.updateJobStatus(jobId, {
progress: 40,
currentStep: 'Применение маппинга колонок'
});
const mappedData = this.applyMapping(rows, mapping, validationResult.fileColumns);
// Сохранение в БД
this.updateJobStatus(jobId, {
progress: 60,
currentStep: 'Сохранение данных в базу'
});
const saveResult = await this.saveToDatabase(mappedData, reportId, jobId);
// Финальный статус
this.updateJobStatus(jobId, {
status: saveResult.success ? 'completed' : 'partial',
progress: 100,
currentStep: 'Обработка завершена',
result: saveResult
});
return {
success: saveResult.success,
result: saveResult,
errors: this.processingJobs.get(jobId).errors
};
} catch (error) {
this.updateJobStatus(jobId, {
status: 'failed',
progress: 100,
currentStep: 'Ошибка обработки',
errors: [{
row: 0,
message: error.message,
suggestion: 'Проверьте формат файла и повторите попытку'
}]
});
return {
jobId,
success: false,
errors: [{ message: error.message }]
};
}
}
/**
* Парсинг CSV файла
*/
async parseCSV(filePath, jobId) {
return new Promise((resolve, reject) => {
const results = [];
let rowCount = 0;
fs.createReadStream(filePath)
.pipe(csv())
.on('data', (data) => {
results.push(data);
rowCount++;
// Обновляем прогресс каждые 100 строк
if (rowCount % 100 === 0) {
this.updateJobStatus(jobId, {
progress: Math.min(15, (rowCount / 1000) * 15),
currentStep: `Чтение CSV: обработано ${rowCount} строк`
});
}
})
.on('end', () => {
this.updateJobStatus(jobId, {
progress: 15,
currentStep: `CSV прочитан: ${rowCount} строк`
});
resolve(results);
})
.on('error', reject);
});
}
/**
* Парсинг XLSX файла
*/
async parseXLSX(filePath, jobId) {
this.updateJobStatus(jobId, {
progress: 5,
currentStep: 'Чтение XLSX файла'
});
try {
const workbook = XLSX.readFile(filePath);
const sheetName = workbook.SheetNames[0]; // Берем первый лист
const worksheet = workbook.Sheets[sheetName];
// Определяем диапазон данных
const range = XLSX.utils.decode_range(worksheet['!ref'] || 'A1:Z1000');
// Ключевые слова для поиска строки с заголовками таблицы
const headerKeywords = [
'адрес', 'наименование', 'название', 'объект', 'дом', 'здание',
'сумма', 'доход', 'расход', 'дебет', 'кредит', 'баланс',
'период', 'дата', 'месяц', 'год',
'номер', '№', 'код'
];
// Ищем строку с заголовками (проверяем первые 30 строк)
let headerRow = -1;
let maxMatches = 0;
for (let row = 0; row <= Math.min(range.e.r, 30); row++) {
let matches = 0;
const rowValues = [];
// Собираем все значения в строке
for (let col = range.s.c; col <= Math.min(range.e.c, 20); col++) {
const cellAddress = XLSX.utils.encode_cell({ r: row, c: col });
const cell = worksheet[cellAddress];
if (cell && cell.v) {
const cellValue = String(cell.v).toLowerCase().trim();
rowValues.push(cellValue);
// Проверяем, содержит ли значение ключевые слова
for (const keyword of headerKeywords) {
if (cellValue.includes(keyword)) {
matches++;
break;
}
}
}
}
// Если в строке найдено достаточно ключевых слов и есть несколько непустых значений
if (matches >= 2 && rowValues.filter(v => v.length > 0).length >= 3) {
if (matches > maxMatches) {
maxMatches = matches;
headerRow = row;
}
}
}
// Если не нашли строку с заголовками, пробуем найти строку с максимальным количеством непустых ячеек
if (headerRow === -1) {
for (let row = 0; row <= Math.min(range.e.r, 20); row++) {
let nonEmptyCount = 0;
for (let col = range.s.c; col <= Math.min(range.e.c, 20); col++) {
const cellAddress = XLSX.utils.encode_cell({ r: row, c: col });
const cell = worksheet[cellAddress];
if (cell && cell.v && String(cell.v).trim().length > 0) {
nonEmptyCount++;
}
}
if (nonEmptyCount >= 4) {
headerRow = row;
break;
}
}
}
// Если так и не нашли, используем строку 0 (первую)
if (headerRow === -1) {
headerRow = 0;
}
console.log(`[FileProcessor] Найдена строка заголовков: ${headerRow + 1}`);
// Парсим данные начиная со строки заголовков
let rows = XLSX.utils.sheet_to_json(worksheet, {
range: headerRow,
defval: '', // Значение по умолчанию для пустых ячеек
raw: false, // Преобразуем все в строки
blankrows: false // Пропускаем пустые строки
});
// Фильтруем пустые строки (где все значения пустые)
rows = rows.filter(row => {
const values = Object.values(row);
return values.some(v => v !== '' && v !== null && v !== undefined);
});
// Логируем найденные колонки для отладки
if (rows.length > 0) {
const columns = Object.keys(rows[0]);
console.log(`[FileProcessor] Найдено колонок в XLSX: ${columns.length}`);
console.log(`[FileProcessor] Колонки: ${columns.join(', ')}`);
console.log(`[FileProcessor] Первая строка данных:`, rows[0]);
console.log(`[FileProcessor] Всего строк данных: ${rows.length}`);
} else {
console.warn(`[FileProcessor] Не найдено данных в файле после парсинга`);
}
this.updateJobStatus(jobId, {
progress: 15,
currentStep: `XLSX прочитан: ${rows.length} строк, ${rows.length > 0 ? Object.keys(rows[0]).length : 0} колонок`
});
return rows;
} catch (error) {
console.error('[FileProcessor] Ошибка парсинга XLSX:', error);
throw new Error(`Ошибка чтения XLSX файла: ${error.message}`);
}
}
/**
* Валидация структуры файла
*/
validateFileStructure(rows, mapping) {
const errors = [];
const warnings = [];
if (rows.length === 0) {
errors.push({
row: 0,
message: 'Файл пуст или не содержит данных',
suggestion: 'Убедитесь, что файл содержит данные и имеет правильный формат'
});
return { valid: false, errors, warnings };
}
// Получаем колонки из файла
const firstRow = rows[0];
const fileColumns = Object.keys(firstRow);
console.log(`[FileProcessor] Валидация: найдено ${fileColumns.length} колонок в файле`);
console.log(`[FileProcessor] Колонки файла:`, fileColumns);
// Проверяем наличие обязательных колонок согласно маппингу
const requiredColumns = Object.keys(mapping.columnMappings || {});
console.log(`[FileProcessor] Ожидаемые колонки из маппинга:`, requiredColumns);
// Пробуем найти колонки с похожими названиями (нечувствительно к регистру и пробелам)
const normalizeColumn = (col) => col.toLowerCase().trim().replace(/\s+/g, ' ');
const normalizedFileColumns = fileColumns.map(col => ({ original: col, normalized: normalizeColumn(col) }));
const normalizedRequiredColumns = requiredColumns.map(col => ({ original: col, normalized: normalizeColumn(col) }));
const missingColumns = [];
const foundColumns = [];
for (const reqCol of normalizedRequiredColumns) {
const found = normalizedFileColumns.find(fc =>
fc.normalized === reqCol.normalized ||
fc.normalized.includes(reqCol.normalized) ||
reqCol.normalized.includes(fc.normalized)
);
if (found) {
foundColumns.push({ required: reqCol.original, found: found.original });
} else {
missingColumns.push(reqCol.original);
}
}
if (missingColumns.length > 0) {
warnings.push({
row: 0,
message: `Не найдены некоторые колонки: ${missingColumns.join(', ')}`,
suggestion: `Доступные колонки: ${fileColumns.join(', ')}. Возможно, нужно настроить маппинг.`
});
}
// Проверяем наличие колонки с адресом (для привязки к домам)
const addressMapping = Object.entries(mapping.columnMappings || {})
.find(([_, target]) => target === 'address');
let addressColumn = null;
if (addressMapping) {
const [reqCol] = addressMapping;
const normalizedReq = normalizeColumn(reqCol);
const found = normalizedFileColumns.find(fc =>
fc.normalized === normalizedReq ||
fc.normalized.includes(normalizedReq) ||
normalizedReq.includes(fc.normalized)
);
addressColumn = found ? found.original : null;
}
// Если не нашли точное совпадение, ищем колонки с похожими названиями (адрес, адр и т.д.)
if (!addressColumn) {
const addressKeywords = ['адрес', 'адр', 'address', 'дом', 'здание'];
const found = normalizedFileColumns.find(fc =>
addressKeywords.some(keyword => fc.normalized.includes(keyword))
);
if (found) {
addressColumn = found.original;
warnings.push({
row: 0,
message: `Автоматически найдена колонка с адресом: "${found.original}"`,
suggestion: 'Рекомендуется настроить маппинг для точного соответствия'
});
}
}
if (!addressColumn) {
errors.push({
row: 0,
message: 'Не найдена колонка с адресом дома',
suggestion: `Доступные колонки: ${fileColumns.join(', ')}. Настройте маппинг для колонки с адресом.`
});
}
return {
valid: errors.length === 0,
errors,
warnings,
foundColumns,
fileColumns
};
}
/**
* Применение маппинга колонок
*/
applyMapping(rows, mapping, fileColumns = null) {
const mappedData = [];
// Нормализуем названия колонок для нечеткого поиска
const normalizeColumn = (col) => col.toLowerCase().trim().replace(/\s+/g, ' ');
// Создаем карту нормализованных колонок файла
const fileColMap = {};
if (fileColumns) {
fileColumns.forEach(col => {
fileColMap[normalizeColumn(col)] = col;
});
} else if (rows.length > 0) {
Object.keys(rows[0]).forEach(col => {
fileColMap[normalizeColumn(col)] = col;
});
}
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const mappedRow = {};
for (const [sourceColumn, targetField] of Object.entries(mapping.columnMappings)) {
// Пробуем точное совпадение
let value = row[sourceColumn];
// Если не найдено, пробуем нечеткое совпадение
if (value === undefined || value === null || value === '') {
const normalizedSource = normalizeColumn(sourceColumn);
const foundCol = fileColMap[normalizedSource];
if (foundCol) {
value = row[foundCol];
} else {
// Пробуем частичное совпадение
for (const [normCol, origCol] of Object.entries(fileColMap)) {
if (normCol.includes(normalizedSource) || normalizedSource.includes(normCol)) {
value = row[origCol];
break;
}
}
}
}
if (value !== undefined && value !== null && value !== '') {
mappedRow[targetField] = this.parseValue(value, targetField);
}
}
// Добавляем исходную строку для отладки
mappedRow._sourceRow = i + 1;
mappedRow._rawData = row;
mappedData.push(mappedRow);
}
return mappedData;
}
/**
* Парсинг значения в зависимости от типа поля
*/
parseValue(value, fieldName) {
if (value === null || value === undefined || value === '') {
return null;
}
// Числовые поля
if (fieldName.includes('amount') || fieldName.includes('income') ||
fieldName.includes('expense') || fieldName.includes('balance') ||
fieldName.includes('cost') || fieldName.includes('price')) {
const num = parseFloat(String(value).replace(/\s/g, '').replace(',', '.'));
return isNaN(num) ? null : num;
}
// Даты
if (fieldName.includes('date') || fieldName.includes('period')) {
// Пробуем разные форматы дат
const date = new Date(value);
if (!isNaN(date.getTime())) {
return date.toISOString().split('T')[0];
}
return value; // Оставляем как есть, если не удалось распарсить
}
// Текстовые поля
return String(value).trim();
}
/**
* Сохранение данных в базу данных
*/
async saveToDatabase(mappedData, reportId, jobId) {
const client = await this.pool.connect();
let processedRows = 0;
let errorRows = 0;
let buildingsFound = 0;
try {
await client.query('BEGIN');
for (let i = 0; i < mappedData.length; i++) {
const row = mappedData[i];
const rowNum = row._sourceRow;
try {
// Находим дом по адресу (пробуем разные варианты названий полей)
const address = row.address || row.buildingAddress || row.адрес || row['Адрес'] ||
row['адрес дома'] || row['Адрес дома'] || row['Адрес здания'] ||
(row._rawData && (row._rawData.address || row._rawData.Адрес || row._rawData['Адрес']));
if (!address || String(address).trim() === '') {
// Логируем для отладки
console.log(`[FileProcessor] Строка ${rowNum}: адрес не найден. Доступные поля:`, Object.keys(row));
this.addError(jobId, {
row: rowNum,
message: 'Не указан адрес дома',
suggestion: `Доступные поля в строке: ${Object.keys(row).filter(k => !k.startsWith('_')).join(', ')}. Убедитесь, что в маппинге указана колонка для адреса.`
});
errorRows++;
continue;
}
const addressStr = String(address).trim();
// Ищем дом в БД по адресу
let buildingResult = await client.query(
`SELECT id FROM buildings WHERE data->'passport'->>'address' = $1`,
[addressStr]
);
let buildingId;
// Если дом не найден, пропускаем строку (не создаем автоматически)
if (buildingResult.rows.length === 0) {
console.log(`[fileProcessor] ⚠ Дом не найден в базе, пропускаем: "${addressStr}"`);
// Добавляем в список не найденных адресов
const current = this.processingJobs.get(jobId);
if (current) {
if (!current.notFoundAddresses) {
current.notFoundAddresses = [];
}
if (!current.notFoundAddresses.includes(addressStr)) {
current.notFoundAddresses.push(addressStr);
}
this.processingJobs.set(jobId, current);
}
errorRows++;
continue;
} else {
buildingId = buildingResult.rows[0].id;
buildingsFound++;
}
// Определяем период
const periodStart = row.periodStart || row.period_start || row.startDate || row.date || new Date().toISOString().split('T')[0];
const periodEnd = row.periodEnd || row.period_end || row.endDate || periodStart;
const periodType = row.periodType || row.period_type || 'month';
// Логируем для отладки первые несколько строк
if (i < 3) {
console.log(`[FileProcessor] Обработка строки ${i + 1}:`, {
address,
periodStart,
totalIncome: row.totalIncome || row.total_income,
totalExpenses: row.totalExpenses || row.total_expenses,
balance: row.balance
});
}
// Подготавливаем данные
const incomeByItems = {};
const expensesByItems = {};
// Собираем доходы и расходы по статьям
for (const [key, value] of Object.entries(row)) {
if (key.startsWith('income_') && typeof value === 'number') {
const itemName = key.replace('income_', '').replace(/_/g, ' ');
incomeByItems[itemName] = (incomeByItems[itemName] || 0) + value;
} else if (key.startsWith('expense_') && typeof value === 'number') {
const itemName = key.replace('expense_', '').replace(/_/g, ' ');
expensesByItems[itemName] = (expensesByItems[itemName] || 0) + value;
}
}
const totalIncome = row.totalIncome || row.total_income ||
Object.values(incomeByItems).reduce((sum, val) => sum + (val || 0), 0);
const totalExpenses = row.totalExpenses || row.total_expenses ||
Object.values(expensesByItems).reduce((sum, val) => sum + (val || 0), 0);
const balance = row.balance || (totalIncome - totalExpenses);
// Сохраняем или обновляем данные
await client.query(
`INSERT INTO building_financial_data
(building_id, report_id, period_start, period_end, period_type,
total_income, income_by_items, total_expenses, expenses_by_items, balance, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (building_id, period_start, period_end, period_type)
DO UPDATE SET
total_income = EXCLUDED.total_income,
income_by_items = EXCLUDED.income_by_items,
total_expenses = EXCLUDED.total_expenses,
expenses_by_items = EXCLUDED.expenses_by_items,
balance = EXCLUDED.balance,
metadata = EXCLUDED.metadata,
updated_at = NOW()`,
[
buildingId,
reportId,
periodStart,
periodEnd,
periodType,
totalIncome,
JSON.stringify(incomeByItems),
totalExpenses,
JSON.stringify(expensesByItems),
balance,
JSON.stringify(row.metadata || {})
]
);
processedRows++;
// Обновляем прогресс
if (i % 10 === 0) {
const progress = 60 + Math.floor((i / mappedData.length) * 35);
this.updateJobStatus(jobId, {
progress,
currentStep: `Обработано ${i + 1} из ${mappedData.length} строк`
});
}
} catch (error) {
this.addError(jobId, {
row: rowNum,
message: error.message,
suggestion: 'Проверьте данные в этой строке'
});
errorRows++;
}
}
await client.query('COMMIT');
// Обновляем статус отчета
if (reportId) {
const jobStatus = this.processingJobs.get(jobId);
const notFoundAddresses = jobStatus?.notFoundAddresses || [];
// Формируем error_log с ошибками и не найденными адресами
const errorLog = {
errors: jobStatus?.errors || [],
notFoundAddresses: notFoundAddresses,
message: notFoundAddresses.length > 0
? `Не найдено ${notFoundAddresses.length} домов из ${mappedData.length}. Данные по этим адресам не были загружены. Создайте дома вручную в системе, затем загрузите отчет повторно.`
: undefined
};
await client.query(
`UPDATE financial_reports
SET status = $1, processed_rows = $2, error_rows = $3, error_log = $4
WHERE id = $5`,
[
errorRows === 0 && notFoundAddresses.length === 0 ? 'completed' : (processedRows > 0 ? 'partial' : 'failed'),
processedRows,
errorRows + notFoundAddresses.length,
JSON.stringify(errorLog),
reportId
]
);
}
const jobStatus = this.processingJobs.get(jobId);
const notFoundAddresses = jobStatus?.notFoundAddresses || [];
return {
success: processedRows > 0,
totalRows: mappedData.length,
processedRows,
errorRows: errorRows + notFoundAddresses.length,
buildingsFound,
buildingsCreated: 0, // Больше не создаем дома автоматически
notFoundAddresses
};
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
/**
* Создание нового дома, если его нет в БД
*/
async createBuildingIfNotExists(client, address, jobId) {
try {
// Получаем первый доступный участок (district) для нового дома
const districtResult = await client.query(
'SELECT id FROM districts ORDER BY id LIMIT 1'
);
const districtId = districtResult.rows.length > 0
? districtResult.rows[0].id
: 'd-1'; // Дефолтный участок, если нет участков
// Генерируем ID для нового дома
const buildingId = `b-auto-${Date.now()}-${Math.random().toString(36).substr(2, 6)}`;
// Создаем минимальную структуру Building
const newBuilding = {
id: buildingId,
districtId: districtId,
imageUrl: "https://picsum.photos/id/122/800/600",
nps: 0,
passport: {
address: address,
apartmentsCount: 0,
general: {
address: address,
fiasCode: "",
constructionYear: new Date().getFullYear(),
commissionYear: new Date().getFullYear(),
seriesType: "Не указано",
floors: 0,
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: ""
},
meters: [],
lifts: [],
land: {
area: 0,
hasPlayground: false,
hasSportsGround: false,
hasParking: false,
hasFencing: false,
hasContainerSite: false
},
management: {
contractDate: "",
contractNumber: "",
servicesList: [],
tariffMaintenance: 0,
reserveFund: 5.00,
serviceContracts: []
}
},
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
};
// Сохраняем новый дом в БД
await client.query(
'INSERT INTO buildings (id, data) VALUES ($1, $2)',
[buildingId, JSON.stringify(newBuilding)]
);
// Автоматически создаем опрос NPS для нового дома
try {
const existingSurvey = await client.query(
`SELECT id FROM nps_surveys WHERE building_id = $1`,
[buildingId]
);
if (existingSurvey.rows.length === 0) {
const accessKey = `nps-${buildingId}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
await client.query(
`INSERT INTO nps_surveys
(building_id, title, description, status, access_key, created_by)
VALUES ($1, $2, $3, $4, $5, 'system')`,
[
buildingId,
'Опрос удовлетворенности жителей',
'Помогите нам стать лучше! Поделитесь своим мнением о качестве обслуживания.',
'draft',
accessKey
]
);
}
} catch (npsErr) {
console.error(`[fileProcessor] Ошибка создания опроса NPS для дома ${buildingId}:`, npsErr);
}
// Автоматически создаем отчет для нового дома (текущий месяц) с заполненными данными
try {
const now = new Date();
const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const currentMonthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59);
const months = [
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
];
const monthName = months[now.getMonth()];
const reportMonth = `${monthName} ${now.getFullYear()}`;
const existingReport = await client.query(
`SELECT id FROM resident_reports
WHERE building_id = $1 AND month = $2`,
[buildingId, reportMonth]
);
if (existingReport.rows.length === 0) {
// Создаем отчет с начальными данными
const initialContent = {
applications: {
total: 156,
completed: 153,
quality: 98
},
finances: {
collected: 2400000,
expenses: 1890000,
balance: 560000
},
nps: {
score: 72,
totalResponses: 45
}
};
await client.query(
`INSERT INTO resident_reports
(building_id, month, period_start, period_end, status, content, published_at)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, NOW())`,
[
buildingId,
reportMonth,
currentMonthStart.toISOString().split('T')[0],
currentMonthEnd.toISOString().split('T')[0],
'published',
JSON.stringify(initialContent)
]
);
}
} catch (reportErr) {
console.error(`[fileProcessor] Ошибка создания отчета для дома ${buildingId}:`, reportErr);
}
// Добавляем информационное сообщение (не ошибку) о создании дома
const current = this.processingJobs.get(jobId);
if (current) {
if (!current.warnings) {
current.warnings = [];
}
current.warnings.push({
message: `Автоматически создан новый дом: "${address}"`,
suggestion: 'Рекомендуется заполнить паспорт дома вручную'
});
this.processingJobs.set(jobId, current);
}
return buildingId;
} catch (error) {
console.error('Ошибка создания нового дома:', error);
this.addError(jobId, {
row: 0,
message: `Не удалось создать дом "${address}": ${error.message}`,
suggestion: 'Проверьте права доступа к БД или создайте дом вручную'
});
return null;
}
}
/**
* Получение статуса обработки
*/
getJobStatus(jobId) {
return this.processingJobs.get(jobId) || null;
}
/**
* Обновление статуса обработки
*/
updateJobStatus(jobId, updates) {
const current = this.processingJobs.get(jobId);
if (current) {
this.processingJobs.set(jobId, { ...current, ...updates });
}
}
/**
* Добавление ошибки
*/
addError(jobId, error) {
const current = this.processingJobs.get(jobId);
if (current) {
current.errors.push(error);
this.processingJobs.set(jobId, current);
}
}
/**
* Очистка старых заданий (старше 1 часа)
*/
cleanupOldJobs() {
const oneHourAgo = Date.now() - 3600000;
for (const [jobId, job] of this.processingJobs.entries()) {
if (job.status === 'completed' || job.status === 'failed') {
// Можно добавить timestamp и удалять старые
// Пока оставляем все для истории
}
}
}
}
module.exports = FileProcessor;

View File

@@ -0,0 +1,19 @@
-- Миграция: добавление статуса 'deferred' в enum doma_application_status
-- Выполнить: psql -d mkd_control_center -f migrate_add_deferred_status.sql
-- Проверяем, существует ли уже значение 'deferred' в enum
DO $$
BEGIN
-- Проверяем, есть ли уже значение 'deferred' в enum
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = 'deferred'
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'doma_application_status')
) THEN
-- Добавляем 'deferred' после 'in_progress'
ALTER TYPE doma_application_status ADD VALUE IF NOT EXISTS 'deferred' AFTER 'in_progress';
RAISE NOTICE 'Статус "deferred" успешно добавлен в enum doma_application_status';
ELSE
RAISE NOTICE 'Статус "deferred" уже существует в enum doma_application_status';
END IF;
END $$;

View File

@@ -0,0 +1,93 @@
-- Миграция: добавление полей для отслеживания производительности и просрочек
-- Выполнить: psql -d mkd_control_center -f migrate_add_performance_tracking.sql
-- Добавляем поля в таблицу applications, если их ещё нет
DO $$
BEGIN
-- Добавляем building_id, если его нет
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'applications' AND column_name = 'building_id'
) THEN
ALTER TABLE applications ADD COLUMN building_id VARCHAR(50) REFERENCES buildings(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_applications_building ON applications(building_id);
END IF;
-- Добавляем employee_id, если его нет
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'applications' AND column_name = 'employee_id'
) THEN
ALTER TABLE applications ADD COLUMN employee_id VARCHAR(50) REFERENCES employees(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_applications_employee ON applications(employee_id);
END IF;
-- Добавляем is_overdue, если его нет
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'applications' AND column_name = 'is_overdue'
) THEN
ALTER TABLE applications ADD COLUMN is_overdue BOOLEAN NOT NULL DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_applications_overdue ON applications(is_overdue);
END IF;
-- Добавляем updated_at, если его нет
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'applications' AND column_name = 'updated_at'
) THEN
ALTER TABLE applications ADD COLUMN updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
END IF;
-- Создаём индексы, если их ещё нет
CREATE INDEX IF NOT EXISTS idx_applications_performer ON applications(performer_name);
CREATE INDEX IF NOT EXISTS idx_applications_status ON applications(status);
CREATE INDEX IF NOT EXISTS idx_applications_deadline ON applications(deadline_at);
END $$;
-- Создаём таблицы для статистики производительности, если их ещё нет
CREATE TABLE IF NOT EXISTS employee_performance_stats (
id BIGSERIAL PRIMARY KEY,
employee_name TEXT NOT NULL,
period_start DATE NOT NULL,
period_end DATE NOT NULL,
total_assigned INTEGER NOT NULL DEFAULT 0,
total_completed INTEGER NOT NULL DEFAULT 0,
total_overdue INTEGER NOT NULL DEFAULT 0,
total_in_progress INTEGER NOT NULL DEFAULT 0,
total_deferred INTEGER NOT NULL DEFAULT 0,
completion_rate NUMERIC(5, 2) NOT NULL DEFAULT 0,
overdue_rate NUMERIC(5, 2) NOT NULL DEFAULT 0,
performance_score NUMERIC(5, 2) NOT NULL DEFAULT 0,
district_id VARCHAR(50) REFERENCES districts(id) ON DELETE SET NULL,
calculated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(employee_name, period_start, period_end)
);
CREATE INDEX IF NOT EXISTS idx_performance_stats_employee ON employee_performance_stats(employee_name);
CREATE INDEX IF NOT EXISTS idx_performance_stats_district ON employee_performance_stats(district_id);
CREATE INDEX IF NOT EXISTS idx_performance_stats_period ON employee_performance_stats(period_start, period_end);
CREATE INDEX IF NOT EXISTS idx_performance_stats_score ON employee_performance_stats(performance_score DESC);
CREATE TABLE IF NOT EXISTS district_performance_stats (
id BIGSERIAL PRIMARY KEY,
district_id VARCHAR(50) NOT NULL REFERENCES districts(id) ON DELETE CASCADE,
period_start DATE NOT NULL,
period_end DATE NOT NULL,
total_applications INTEGER NOT NULL DEFAULT 0,
total_completed INTEGER NOT NULL DEFAULT 0,
total_overdue INTEGER NOT NULL DEFAULT 0,
total_in_progress INTEGER NOT NULL DEFAULT 0,
completion_rate NUMERIC(5, 2) NOT NULL DEFAULT 0,
overdue_rate NUMERIC(5, 2) NOT NULL DEFAULT 0,
average_score NUMERIC(5, 2) NOT NULL DEFAULT 0,
calculated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(district_id, period_start, period_end)
);
CREATE INDEX IF NOT EXISTS idx_district_stats_district ON district_performance_stats(district_id);
CREATE INDEX IF NOT EXISTS idx_district_stats_period ON district_performance_stats(period_start, period_end);
RAISE NOTICE 'Миграция завершена: добавлены поля для отслеживания производительности';

View File

@@ -0,0 +1,51 @@
-- Миграция для создания таблицы событий кандидатов
-- Выполните этот скрипт для обновления существующей базы данных
-- Создание типа candidate_event_type
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'candidate_event_type') THEN
CREATE TYPE candidate_event_type AS ENUM (
'call', -- Созвон
'interview_1', -- Первое собеседование
'interview_2', -- Второе собеседование
'interview_3', -- Третье собеседование
'test_task', -- Тестовое задание
'offer', -- Оффер
'offer_accepted', -- Оффер принят
'offer_rejected', -- Оффер отклонен
'probation_start', -- Начало испытательного срока
'hired', -- Трудоустроен
'rejected', -- Отклонен
'other' -- Другое
);
END IF;
END$$;
-- Создание типа candidate_event_result
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'candidate_event_result') THEN
CREATE TYPE candidate_event_result AS ENUM ('success', 'failed', 'pending', 'cancelled');
END IF;
END$$;
-- Создание таблицы событий кандидата
CREATE TABLE IF NOT EXISTS candidate_events (
id VARCHAR(50) PRIMARY KEY,
candidate_id VARCHAR(50) NOT NULL REFERENCES candidates(id) ON DELETE CASCADE,
event_type candidate_event_type NOT NULL,
event_date TIMESTAMPTZ NOT NULL,
notes TEXT, -- Заметки о событии
result candidate_event_result DEFAULT 'pending', -- Результат события
interviewer TEXT, -- Кто проводил (для собеседований)
location TEXT, -- Место проведения (офис, онлайн и т.д.)
duration_minutes INTEGER, -- Длительность в минутах
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Создание индексов
CREATE INDEX IF NOT EXISTS idx_candidate_events_candidate ON candidate_events(candidate_id);
CREATE INDEX IF NOT EXISTS idx_candidate_events_type ON candidate_events(event_type);
CREATE INDEX IF NOT EXISTS idx_candidate_events_date ON candidate_events(event_date DESC);

View File

@@ -0,0 +1,17 @@
-- Миграция для добавления статуса 'probation' в candidate_stage
-- Выполните этот скрипт для обновления существующей базы данных
DO $$
BEGIN
-- Проверяем, существует ли тип
IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'candidate_stage') THEN
-- Добавляем 'probation' если его еще нет
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = 'probation'
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'candidate_stage')
) THEN
ALTER TYPE candidate_stage ADD VALUE 'probation';
END IF;
END IF;
END$$;

View File

@@ -0,0 +1,22 @@
-- ========= НАСТРОЙКИ КОМПАНИИ =========
-- Данные о компании для отчетов жителям
CREATE TABLE IF NOT EXISTS company_settings (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL DEFAULT 'Управляющая компания',
full_name TEXT,
address TEXT,
phone TEXT,
email TEXT,
website TEXT,
license_number TEXT,
license_valid_until DATE,
logo_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Вставляем дефолтные данные, если их нет
INSERT INTO company_settings (id, name, full_name, address, phone, email, license_number)
VALUES (1, 'Управляющая компания "Дружба"', 'ООО "Управляющая компания Дружба"', 'г. Уфа, ул. Ленина, 1', '+7 (347) 123-45-67', 'info@uk-druzhba.ru', 'Лицензия №12345')
ON CONFLICT (id) DO NOTHING;

View File

@@ -0,0 +1,39 @@
-- ========= ЮРИДИЧЕСКИЙ ОТДЕЛ: ИСТОРИЯ ПРОВЕРОК КОНТРАГЕНТОВ =========
-- Миграция для создания таблицы истории проверок контрагентов через DaData
CREATE TABLE IF NOT EXISTS counterparty_checks (
id BIGSERIAL PRIMARY KEY,
inn TEXT NOT NULL,
kpp TEXT,
ogrn TEXT,
name TEXT NOT NULL,
short_name TEXT,
type VARCHAR(20) CHECK (type IN ('LEGAL', 'INDIVIDUAL')),
status VARCHAR(20),
registration_date DATE,
liquidation_date DATE,
address TEXT,
okved TEXT,
okveds JSONB,
management_name TEXT,
management_post TEXT,
finance_data JSONB, -- Полные финансовые данные
authorities_data JSONB, -- Данные о налоговой, ПФР, ФСС
phones JSONB,
emails JSONB,
employee_count INTEGER,
risk_level VARCHAR(10) NOT NULL CHECK (risk_level IN ('low', 'medium', 'high')),
risk_reasons TEXT[],
raw_data JSONB, -- Полные данные от DaData
checked_by TEXT, -- Кто провел проверку
checked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_counterparty_checks_inn ON counterparty_checks(inn);
CREATE INDEX IF NOT EXISTS idx_counterparty_checks_risk_level ON counterparty_checks(risk_level);
CREATE INDEX IF NOT EXISTS idx_counterparty_checks_checked_at ON counterparty_checks(checked_at DESC);
CREATE INDEX IF NOT EXISTS idx_counterparty_checks_name ON counterparty_checks(name);
COMMENT ON TABLE counterparty_checks IS 'История проверок контрагентов через DaData API';

View File

@@ -0,0 +1,149 @@
-- ========= МОДУЛЬ РАЗВИТИЯ: МИГРАЦИЯ =========
-- Создание таблиц для модуля "Отдел развития"
-- 1. Воронка потенциальных объектов
CREATE TABLE IF NOT EXISTS development_pipeline (
id VARCHAR(50) PRIMARY KEY,
address TEXT NOT NULL,
type VARCHAR(10) NOT NULL CHECK (type IN ('old', 'new')),
floors INTEGER NOT NULL,
area NUMERIC(10, 2) NOT NULL,
apartments INTEGER NOT NULL,
status VARCHAR(30) NOT NULL CHECK (status IN ('incoming', 'analysis', 'agenda_approval', 'in_person', 'absentee', 'protocol_formation', 'protocol_to_gzhi', 'gzhi_order', 'success', 'failure')),
probability INTEGER NOT NULL CHECK (probability >= 0 AND probability <= 100),
expected_revenue NUMERIC(15, 2) NOT NULL DEFAULT 0,
manager TEXT NOT NULL,
building_id VARCHAR(50) REFERENCES buildings(id) ON DELETE SET NULL,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_dev_pipeline_status ON development_pipeline(status);
CREATE INDEX IF NOT EXISTS idx_dev_pipeline_building ON development_pipeline(building_id);
-- 2. Собрания собственников (ОСС)
CREATE TABLE IF NOT EXISTS development_oss_sessions (
id VARCHAR(50) PRIMARY KEY,
building_id VARCHAR(50) REFERENCES buildings(id) ON DELETE CASCADE,
address TEXT NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
quorum_current NUMERIC(10, 2) NOT NULL DEFAULT 0,
quorum_total NUMERIC(10, 2) NOT NULL,
status VARCHAR(20) NOT NULL CHECK (status IN ('active', 'planned', 'completed')),
type VARCHAR(20) NOT NULL CHECK (type IN ('annual', 'extraordinary')),
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_dev_oss_building ON development_oss_sessions(building_id);
CREATE INDEX IF NOT EXISTS idx_dev_oss_status ON development_oss_sessions(status);
CREATE INDEX IF NOT EXISTS idx_dev_oss_dates ON development_oss_sessions(start_date, end_date);
-- 3. Технические аудиты
CREATE TABLE IF NOT EXISTS development_audits (
id VARCHAR(50) PRIMARY KEY,
building_id VARCHAR(50) REFERENCES buildings(id) ON DELETE CASCADE,
address TEXT NOT NULL,
wear_percent INTEGER NOT NULL CHECK (wear_percent >= 0 AND wear_percent <= 100),
roof_condition VARCHAR(10) NOT NULL CHECK (roof_condition IN ('good', 'poor', 'fair')),
basement_condition VARCHAR(10) NOT NULL CHECK (basement_condition IN ('good', 'poor', 'fair')),
calculated_tariff NUMERIC(10, 2) NOT NULL,
projected_margin NUMERIC(5, 2) NOT NULL,
audit_date DATE NOT NULL DEFAULT CURRENT_DATE,
auditor_name TEXT,
defect_list_url TEXT,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_dev_audits_building ON development_audits(building_id);
CREATE INDEX IF NOT EXISTS idx_dev_audits_date ON development_audits(audit_date DESC);
-- 4. Маркетинговые активности
CREATE TABLE IF NOT EXISTS development_marketing_activities (
id VARCHAR(50) PRIMARY KEY,
building_id VARCHAR(50) REFERENCES buildings(id) ON DELETE CASCADE,
address TEXT NOT NULL,
activists_count INTEGER NOT NULL DEFAULT 0,
meetings_held INTEGER NOT NULL DEFAULT 0,
ads_distributed INTEGER NOT NULL DEFAULT 0,
competitor TEXT,
status VARCHAR(20) NOT NULL CHECK (status IN ('voting', 'my_house', 'competitor_house')),
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_dev_marketing_building ON development_marketing_activities(building_id);
CREATE INDEX IF NOT EXISTS idx_dev_marketing_status ON development_marketing_activities(status);
-- 5. Координаты зданий для карты присутствия
CREATE TABLE IF NOT EXISTS development_building_locations (
id BIGSERIAL PRIMARY KEY,
building_id VARCHAR(50) REFERENCES buildings(id) ON DELETE CASCADE,
address TEXT NOT NULL,
latitude NUMERIC(10, 7),
longitude NUMERIC(10, 7),
status VARCHAR(20) NOT NULL CHECK (status IN ('ours', 'voting', 'competitor')),
competitor_name TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(building_id)
);
CREATE INDEX IF NOT EXISTS idx_dev_locations_building ON development_building_locations(building_id);
CREATE INDEX IF NOT EXISTS idx_dev_locations_status ON development_building_locations(status);
-- 6. Реестр участников ОСС
CREATE TABLE IF NOT EXISTS development_oss_registry (
id BIGSERIAL PRIMARY KEY,
oss_session_id VARCHAR(50) NOT NULL REFERENCES development_oss_sessions(id) ON DELETE CASCADE,
apartment TEXT NOT NULL,
owner_name TEXT,
area NUMERIC(10, 2) NOT NULL,
ballot_submitted BOOLEAN DEFAULT FALSE,
ballot_date DATE,
vote_result VARCHAR(20) CHECK (vote_result IN ('for', 'against', 'abstain')),
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_dev_oss_registry_session ON development_oss_registry(oss_session_id);
-- Триггеры для автоматического обновления updated_at
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
DROP TRIGGER IF EXISTS update_dev_pipeline_updated_at ON development_pipeline;
CREATE TRIGGER update_dev_pipeline_updated_at BEFORE UPDATE ON development_pipeline
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
DROP TRIGGER IF EXISTS update_dev_oss_sessions_updated_at ON development_oss_sessions;
CREATE TRIGGER update_dev_oss_sessions_updated_at BEFORE UPDATE ON development_oss_sessions
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
DROP TRIGGER IF EXISTS update_dev_audits_updated_at ON development_audits;
CREATE TRIGGER update_dev_audits_updated_at BEFORE UPDATE ON development_audits
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
DROP TRIGGER IF EXISTS update_dev_marketing_activities_updated_at ON development_marketing_activities;
CREATE TRIGGER update_dev_marketing_activities_updated_at BEFORE UPDATE ON development_marketing_activities
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
DROP TRIGGER IF EXISTS update_dev_building_locations_updated_at ON development_building_locations;
CREATE TRIGGER update_dev_building_locations_updated_at BEFORE UPDATE ON development_building_locations
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
DROP TRIGGER IF EXISTS update_dev_oss_registry_updated_at ON development_oss_registry;
CREATE TRIGGER update_dev_oss_registry_updated_at BEFORE UPDATE ON development_oss_registry
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();

View File

@@ -0,0 +1,40 @@
-- Таблица для сопоставлений адресов из Doma.AI с домами в локальной БД
CREATE TABLE IF NOT EXISTS doma_address_mappings (
id BIGSERIAL PRIMARY KEY,
doma_address TEXT NOT NULL UNIQUE, -- Адрес из Doma.AI
building_id VARCHAR(50) NOT NULL REFERENCES buildings(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_doma_address_mappings_doma_address ON doma_address_mappings(doma_address);
CREATE INDEX IF NOT EXISTS idx_doma_address_mappings_building ON doma_address_mappings(building_id);
-- Таблица для сопоставлений имён сотрудников из Doma.AI с сотрудниками в локальной БД
CREATE TABLE IF NOT EXISTS doma_employee_mappings (
id BIGSERIAL PRIMARY KEY,
doma_name TEXT NOT NULL UNIQUE, -- Имя из Doma.AI
employee_id VARCHAR(50) NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_doma_employee_mappings_doma_name ON doma_employee_mappings(doma_name);
CREATE INDEX IF NOT EXISTS idx_doma_employee_mappings_employee ON doma_employee_mappings(employee_id);
-- Таблица для ожидающих подтверждения сопоставлений
CREATE TABLE IF NOT EXISTS pending_doma_mappings (
id BIGSERIAL PRIMARY KEY,
type VARCHAR(20) NOT NULL CHECK (type IN ('building', 'employee')),
doma_value TEXT NOT NULL, -- Адрес или имя из Doma.AI
suggested_id VARCHAR(50), -- Предложенный ID существующей записи
suggested_name TEXT, -- Название предложенной записи для отображения
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
resolved_at TIMESTAMPTZ,
resolved_by TEXT, -- Кто разрешил (user_id или 'system')
resolution VARCHAR(20) CHECK (resolution IN ('approved', 'rejected', 'new')), -- approved = использовать suggested_id, rejected = игнорировать, new = создать новую
resolved_id VARCHAR(50) -- ID, который был использован после разрешения
);
CREATE INDEX IF NOT EXISTS idx_pending_mappings_type ON pending_doma_mappings(type);
CREATE INDEX IF NOT EXISTS idx_pending_mappings_resolved ON pending_doma_mappings(resolved_at);

View File

@@ -0,0 +1,18 @@
-- ========= ЮРИДИЧЕСКИЙ ОТДЕЛ: ДОКУМЕНТЫ ПО ТИПАМ =========
-- Таблица документов по судебным делам: претензия, иск, решение, исполнительный лист, постановление ИП
CREATE TABLE IF NOT EXISTS legal_case_documents (
id BIGSERIAL PRIMARY KEY,
case_id VARCHAR(50) NOT NULL REFERENCES legal_court_cases(id) ON DELETE CASCADE,
doc_type VARCHAR(30) NOT NULL CHECK (doc_type IN ('pretenzia', 'isk', 'reshenie', 'ispolnitelny_list', 'postanovlenie_ip', 'other')),
file_url TEXT NOT NULL,
doc_date DATE,
title TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_legal_case_documents_case ON legal_case_documents(case_id);
CREATE INDEX IF NOT EXISTS idx_legal_case_documents_type ON legal_case_documents(doc_type);
CREATE INDEX IF NOT EXISTS idx_legal_case_documents_date ON legal_case_documents(doc_date DESC);
COMMENT ON TABLE legal_case_documents IS 'Документы по судебным делам: претензия, иск, решение, ИЛ, постановление ИП';

View File

@@ -0,0 +1,12 @@
-- ========= ЮРИДИЧЕСКИЙ ОТДЕЛ: ЭТАПЫ ФССП И ИП =========
-- Дополнение к legal_court_cases: номер ИП, дата возбуждения ИП, этап ФССП
-- Этап ФССП: writ_submitted | ip_initiated | bank_requests | money_on_deposit | transferred_to_uk
ALTER TABLE legal_court_cases
ADD COLUMN IF NOT EXISTS enforcement_number TEXT,
ADD COLUMN IF NOT EXISTS enforcement_start_date DATE,
ADD COLUMN IF NOT EXISTS fssp_stage VARCHAR(30);
COMMENT ON COLUMN legal_court_cases.enforcement_number IS 'Номер исполнительного производства (ИП)';
COMMENT ON COLUMN legal_court_cases.enforcement_start_date IS 'Дата возбуждения исполнительного производства';
COMMENT ON COLUMN legal_court_cases.fssp_stage IS 'Этап ФССП: writ_submitted, ip_initiated, bank_requests, money_on_deposit, transferred_to_uk';

140
backend/migrate_legal_module.sql Executable file
View File

@@ -0,0 +1,140 @@
-- ========= ЮРИДИЧЕСКИЙ ОТДЕЛ: ПОЛНАЯ МИГРАЦИЯ =========
-- Миграция для создания всех таблиц юридического отдела
-- Договоры
CREATE TABLE IF NOT EXISTS legal_contracts (
id VARCHAR(50) PRIMARY KEY,
number TEXT NOT NULL UNIQUE,
type TEXT NOT NULL, -- Тип договора (Поставка, Услуги и т.д.)
counterparty TEXT NOT NULL, -- Название контрагента
counterparty_inn TEXT, -- ИНН контрагента (для связи с проверками)
amount NUMERIC(15, 2) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'finance_approval', 'counterparty_approval', 'signing', 'active', 'archived')),
start_date DATE NOT NULL,
end_date DATE NOT NULL,
auto_prolongation BOOLEAN DEFAULT FALSE,
manager TEXT NOT NULL, -- Кто ведет договор
has_disagreements BOOLEAN DEFAULT FALSE,
contract_file_url TEXT, -- Ссылка на файл договора
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_legal_contracts_status ON legal_contracts(status);
CREATE INDEX IF NOT EXISTS idx_legal_contracts_counterparty ON legal_contracts(counterparty);
CREATE INDEX IF NOT EXISTS idx_legal_contracts_number ON legal_contracts(number);
CREATE INDEX IF NOT EXISTS idx_legal_contracts_dates ON legal_contracts(start_date, end_date);
CREATE INDEX IF NOT EXISTS idx_legal_contracts_created ON legal_contracts(created_at DESC);
-- Судебные дела
CREATE TABLE IF NOT EXISTS legal_court_cases (
id VARCHAR(50) PRIMARY KEY,
case_number TEXT NOT NULL UNIQUE,
type VARCHAR(20) NOT NULL CHECK (type IN ('arbitration', 'civil', 'debt_recovery')),
role VARCHAR(20) NOT NULL CHECK (role IN ('plaintiff', 'defendant')),
subject TEXT NOT NULL,
debtor_name TEXT,
address TEXT,
amount NUMERIC(15, 2) NOT NULL,
recovered_amount NUMERIC(15, 2) DEFAULT 0,
amount_at_bailiffs NUMERIC(15, 2) DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'pre_trial' CHECK (status IN ('pre_trial', 'litigation', 'decision_received', 'enforcement', 'closed')),
fssp_status TEXT,
bailiff_name TEXT,
fssp_last_action_date DATE,
next_hearing_date DATE,
judge TEXT,
court_name TEXT,
case_file_url TEXT, -- Ссылка на материалы дела
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_legal_court_cases_status ON legal_court_cases(status);
CREATE INDEX IF NOT EXISTS idx_legal_court_cases_type ON legal_court_cases(type);
CREATE INDEX IF NOT EXISTS idx_legal_court_cases_role ON legal_court_cases(role);
CREATE INDEX IF NOT EXISTS idx_legal_court_cases_number ON legal_court_cases(case_number);
CREATE INDEX IF NOT EXISTS idx_legal_court_cases_hearing ON legal_court_cases(next_hearing_date);
CREATE INDEX IF NOT EXISTS idx_legal_court_cases_created ON legal_court_cases(created_at DESC);
-- Доверенности (оставляем для истории, но можно использовать для других целей)
CREATE TABLE IF NOT EXISTS legal_powers_of_attorney (
id VARCHAR(50) PRIMARY KEY,
number TEXT NOT NULL UNIQUE,
issued_to TEXT NOT NULL, -- Кому выдана
issue_date DATE NOT NULL,
expiry_date DATE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'expired', 'revoked')),
authority TEXT NOT NULL, -- Полномочия
document_file_url TEXT, -- Ссылка на файл доверенности
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_legal_poa_status ON legal_powers_of_attorney(status);
CREATE INDEX IF NOT EXISTS idx_legal_poa_expiry ON legal_powers_of_attorney(expiry_date);
CREATE INDEX IF NOT EXISTS idx_legal_poa_number ON legal_powers_of_attorney(number);
-- История изменений договоров
CREATE TABLE IF NOT EXISTS legal_contract_history (
id BIGSERIAL PRIMARY KEY,
contract_id VARCHAR(50) NOT NULL REFERENCES legal_contracts(id) ON DELETE CASCADE,
from_status VARCHAR(20),
to_status VARCHAR(20) NOT NULL,
changed_by TEXT NOT NULL,
change_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_contract_history_contract ON legal_contract_history(contract_id);
CREATE INDEX IF NOT EXISTS idx_contract_history_date ON legal_contract_history(created_at DESC);
-- История изменений судебных дел
CREATE TABLE IF NOT EXISTS legal_court_case_history (
id BIGSERIAL PRIMARY KEY,
case_id VARCHAR(50) NOT NULL REFERENCES legal_court_cases(id) ON DELETE CASCADE,
from_status VARCHAR(20),
to_status VARCHAR(20) NOT NULL,
changed_by TEXT NOT NULL,
change_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_court_case_history_case ON legal_court_case_history(case_id);
CREATE INDEX IF NOT EXISTS idx_court_case_history_date ON legal_court_case_history(created_at DESC);
-- Комментарии к договорам
CREATE TABLE IF NOT EXISTS legal_contract_comments (
id BIGSERIAL PRIMARY KEY,
contract_id VARCHAR(50) NOT NULL REFERENCES legal_contracts(id) ON DELETE CASCADE,
author TEXT NOT NULL,
comment TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_contract_comments_contract ON legal_contract_comments(contract_id);
CREATE INDEX IF NOT EXISTS idx_contract_comments_date ON legal_contract_comments(created_at DESC);
-- Комментарии к судебным делам
CREATE TABLE IF NOT EXISTS legal_court_case_comments (
id BIGSERIAL PRIMARY KEY,
case_id VARCHAR(50) NOT NULL REFERENCES legal_court_cases(id) ON DELETE CASCADE,
author TEXT NOT NULL,
comment TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_court_case_comments_case ON legal_court_case_comments(case_id);
CREATE INDEX IF NOT EXISTS idx_court_case_comments_date ON legal_court_case_comments(created_at DESC);
-- Комментарии для документации
COMMENT ON TABLE legal_contracts IS 'Договоры юридического отдела';
COMMENT ON TABLE legal_court_cases IS 'Судебные дела';
COMMENT ON TABLE legal_powers_of_attorney IS 'Доверенности';
COMMENT ON TABLE legal_contract_history IS 'История изменений статусов договоров';
COMMENT ON TABLE legal_court_case_history IS 'История изменений статусов судебных дел';
COMMENT ON TABLE legal_contract_comments IS 'Комментарии к договорам';
COMMENT ON TABLE legal_court_case_comments IS 'Комментарии к судебным делам';

View File

@@ -0,0 +1,9 @@
-- ========= ЮРИДИЧЕСКИЙ ОТДЕЛ: ПЕНИ ПО ДЕЛАМ =========
-- Дополнение к legal_court_cases: сумма пени, дата начала просрочки
ALTER TABLE legal_court_cases
ADD COLUMN IF NOT EXISTS penalty_amount NUMERIC(15, 2) DEFAULT 0,
ADD COLUMN IF NOT EXISTS overdue_since DATE;
COMMENT ON COLUMN legal_court_cases.penalty_amount IS 'Сумма пени по делу (1/300, с 91 дня 1/130 ключевой ставки)';
COMMENT ON COLUMN legal_court_cases.overdue_since IS 'Дата начала просрочки оплаты';

View File

@@ -0,0 +1,25 @@
-- ========= АГРЕГИРОВАННЫЕ ПОКАЗАТЕЛИ NPS ПО ДОМУ И ПЕРИОДУ =========
-- Сохранение цифр NPS (score, кол-во ответов, промоутеры и т.д.) для отчетов
CREATE TABLE IF NOT EXISTS nps_building_stats (
id BIGSERIAL PRIMARY KEY,
building_id VARCHAR(50) NOT NULL REFERENCES buildings(id) ON DELETE CASCADE,
period_start DATE NOT NULL,
period_end DATE NOT NULL,
total_responses INTEGER NOT NULL DEFAULT 0,
nps_score INTEGER NOT NULL DEFAULT 0, -- NPS = % промоутеров - % критиков
avg_score NUMERIC(4,2) NOT NULL DEFAULT 0, -- средняя оценка 0-10
promoters INTEGER NOT NULL DEFAULT 0, -- оценка 9-10
passives INTEGER NOT NULL DEFAULT 0, -- оценка 7-8
detractors INTEGER NOT NULL DEFAULT 0, -- оценка 0-6
promoter_percent NUMERIC(5,2) DEFAULT 0,
detractor_percent NUMERIC(5,2) DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(building_id, period_start, period_end)
);
CREATE INDEX IF NOT EXISTS idx_nps_building_stats_building ON nps_building_stats(building_id);
CREATE INDEX IF NOT EXISTS idx_nps_building_stats_period ON nps_building_stats(period_start, period_end);
COMMENT ON TABLE nps_building_stats IS 'Агрегированные показатели NPS по дому за период (для отчетов жителям)';

40
backend/migrate_nps_surveys.sql Executable file
View File

@@ -0,0 +1,40 @@
-- ========= NPS ОПРОСЫ =========
-- Создание таблиц для опросов NPS
-- ========= ОПРОСЫ NPS =========
CREATE TABLE IF NOT EXISTS nps_surveys (
id BIGSERIAL PRIMARY KEY,
building_id VARCHAR(50) NOT NULL REFERENCES buildings(id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT 'Опрос удовлетворенности',
description TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'closed')),
access_key TEXT NOT NULL UNIQUE, -- Уникальный ключ доступа для ссылки
published_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ, -- Дата окончания опроса
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_nps_surveys_building ON nps_surveys(building_id);
CREATE INDEX IF NOT EXISTS idx_nps_surveys_status ON nps_surveys(status);
CREATE INDEX IF NOT EXISTS idx_nps_surveys_access_key ON nps_surveys(access_key);
-- ========= ОТВЕТЫ НА ОПРОСЫ NPS =========
CREATE TABLE IF NOT EXISTS nps_responses (
id BIGSERIAL PRIMARY KEY,
survey_id BIGINT NOT NULL REFERENCES nps_surveys(id) ON DELETE CASCADE,
building_id VARCHAR(50) NOT NULL REFERENCES buildings(id) ON DELETE CASCADE,
score INTEGER NOT NULL CHECK (score >= 0 AND score <= 10), -- Оценка от 0 до 10
comment TEXT, -- Комментарий (опционально)
respondent_name TEXT, -- Имя респондента (опционально)
apartment TEXT, -- Квартира (опционально)
phone TEXT, -- Телефон (опционально)
email TEXT, -- Email (опционально)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_nps_responses_survey ON nps_responses(survey_id);
CREATE INDEX IF NOT EXISTS idx_nps_responses_building ON nps_responses(building_id);
CREATE INDEX IF NOT EXISTS idx_nps_responses_score ON nps_responses(score);
CREATE INDEX IF NOT EXISTS idx_nps_responses_created ON nps_responses(created_at DESC);

View File

@@ -0,0 +1,131 @@
-- Миграция для автоматизации воронки развития
-- Таблица истории переходов
CREATE TABLE IF NOT EXISTS development_pipeline_history (
id BIGSERIAL PRIMARY KEY,
pipeline_id VARCHAR(50) NOT NULL REFERENCES development_pipeline(id) ON DELETE CASCADE,
from_status VARCHAR(20),
to_status VARCHAR(20) NOT NULL,
reason TEXT,
triggered_by VARCHAR(50) NOT NULL DEFAULT 'manual', -- 'auto' | 'manual' | user_id
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_pipeline_history_pipeline ON development_pipeline_history(pipeline_id);
CREATE INDEX IF NOT EXISTS idx_pipeline_history_status ON development_pipeline_history(to_status);
CREATE INDEX IF NOT EXISTS idx_pipeline_history_date ON development_pipeline_history(created_at DESC);
-- Таблица правил автоматизации (для будущего расширения)
CREATE TABLE IF NOT EXISTS development_automation_rules (
id BIGSERIAL PRIMARY KEY,
rule_name VARCHAR(100) NOT NULL UNIQUE,
from_status VARCHAR(20),
to_status VARCHAR(20) NOT NULL,
conditions JSONB, -- условия для срабатывания
enabled BOOLEAN DEFAULT TRUE,
priority INTEGER DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_automation_rules_status ON development_automation_rules(from_status, to_status);
CREATE INDEX IF NOT EXISTS idx_automation_rules_enabled ON development_automation_rules(enabled);
-- Добавляем поле для отслеживания последнего автоматического обновления
ALTER TABLE development_pipeline
ADD COLUMN IF NOT EXISTS last_auto_check TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_pipeline_last_check ON development_pipeline(last_auto_check);
-- Триггер для автоматического обновления статуса при создании ОСС
CREATE OR REPLACE FUNCTION auto_transition_to_voting()
RETURNS TRIGGER AS $$
BEGIN
-- Если создано активное ОСС, переводим связанный pipeline объект в voting
IF NEW.status = 'active' THEN
UPDATE development_pipeline
SET status = 'voting', updated_at = NOW()
WHERE (building_id = NEW.building_id OR address = NEW.address)
AND status = 'preparation';
-- Логируем переход
INSERT INTO development_pipeline_history (pipeline_id, from_status, to_status, reason, triggered_by)
SELECT id, 'preparation', 'voting', 'Автоматический переход: создано активное ОСС', 'auto'
FROM development_pipeline
WHERE (building_id = NEW.building_id OR address = NEW.address)
AND status = 'voting'
AND id NOT IN (SELECT pipeline_id FROM development_pipeline_history WHERE to_status = 'voting' AND created_at > NOW() - INTERVAL '1 minute');
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trigger_auto_transition_to_voting ON development_oss_sessions;
CREATE TRIGGER trigger_auto_transition_to_voting
AFTER INSERT OR UPDATE ON development_oss_sessions
FOR EACH ROW
WHEN (NEW.status = 'active')
EXECUTE FUNCTION auto_transition_to_voting();
-- Триггер для обработки завершения ОСС
CREATE OR REPLACE FUNCTION auto_handle_oss_completion()
RETURNS TRIGGER AS $$
DECLARE
quorum_percent NUMERIC;
BEGIN
-- Если ОСС завершено
IF NEW.status = 'completed' AND OLD.status != 'completed' THEN
quorum_percent := (NEW.quorum_current / NULLIF(NEW.quorum_total, 0)) * 100;
-- Если кворум > 50%, переводим в transfer
IF quorum_percent > 50 THEN
UPDATE development_pipeline
SET status = 'transfer', updated_at = NOW()
WHERE (building_id = NEW.building_id OR address = NEW.address)
AND status = 'voting';
-- Логируем переход
INSERT INTO development_pipeline_history (pipeline_id, from_status, to_status, reason, triggered_by)
SELECT id, 'voting', 'transfer',
format('Автоматический переход: ОСС завершено успешно (кворум %.1f%%)', quorum_percent),
'auto'
FROM development_pipeline
WHERE (building_id = NEW.building_id OR address = NEW.address)
AND status = 'transfer'
AND id NOT IN (SELECT pipeline_id FROM development_pipeline_history WHERE to_status = 'transfer' AND created_at > NOW() - INTERVAL '1 minute');
ELSE
-- ОСС провалено - снижаем probability
UPDATE development_pipeline
SET probability = GREATEST(0, probability - 20), updated_at = NOW()
WHERE (building_id = NEW.building_id OR address = NEW.address)
AND status = 'voting';
-- Откатываем в preparation
UPDATE development_pipeline
SET status = 'preparation', updated_at = NOW()
WHERE (building_id = NEW.building_id OR address = NEW.address)
AND status = 'voting';
-- Логируем откат
INSERT INTO development_pipeline_history (pipeline_id, from_status, to_status, reason, triggered_by)
SELECT id, 'voting', 'preparation',
format('Автоматический откат: ОСС провалено (кворум %.1f%%)', quorum_percent),
'auto'
FROM development_pipeline
WHERE (building_id = NEW.building_id OR address = NEW.address)
AND status = 'preparation'
AND id NOT IN (SELECT pipeline_id FROM development_pipeline_history WHERE to_status = 'preparation' AND created_at > NOW() - INTERVAL '1 minute');
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trigger_auto_handle_oss_completion ON development_oss_sessions;
CREATE TRIGGER trigger_auto_handle_oss_completion
AFTER UPDATE ON development_oss_sessions
FOR EACH ROW
WHEN (NEW.status = 'completed' AND OLD.status != 'completed')
EXECUTE FUNCTION auto_handle_oss_completion();

107
backend/migrate_pr_nps.sql Executable file
View File

@@ -0,0 +1,107 @@
-- ========= PR и NPS: МИГРАЦИЯ =========
-- Создание таблиц для функционала PR и NPS
-- ========= ОТЗЫВЫ =========
CREATE TABLE IF NOT EXISTS reviews (
id BIGSERIAL PRIMARY KEY,
building_id VARCHAR(50) REFERENCES buildings(id) ON DELETE CASCADE,
source VARCHAR(20) NOT NULL CHECK (source IN ('yandex_maps', '2gis', 'internal', 'other')),
source_url TEXT,
author_name TEXT,
text TEXT NOT NULL,
rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 10),
date DATE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'new' CHECK (status IN ('new', 'processed', 'archived')),
processed_at TIMESTAMPTZ,
processed_by TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_reviews_building ON reviews(building_id);
CREATE INDEX IF NOT EXISTS idx_reviews_source ON reviews(source);
CREATE INDEX IF NOT EXISTS idx_reviews_status ON reviews(status);
CREATE INDEX IF NOT EXISTS idx_reviews_date ON reviews(date DESC);
-- ========= ИНЦИДЕНТЫ =========
CREATE TABLE IF NOT EXISTS incidents (
id BIGSERIAL PRIMARY KEY,
review_id BIGINT REFERENCES reviews(id) ON DELETE SET NULL,
building_id VARCHAR(50) REFERENCES buildings(id) ON DELETE CASCADE,
type VARCHAR(50) NOT NULL CHECK (type IN ('property_damage', 'debtor_complaint', 'service_quality', 'other')),
title TEXT NOT NULL,
description TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'new' CHECK (status IN ('new', 'in_progress', 'resolved', 'closed')),
priority VARCHAR(10) NOT NULL DEFAULT 'medium' CHECK (priority IN ('low', 'medium', 'high', 'urgent')),
assigned_to TEXT,
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
resolved_at TIMESTAMPTZ,
resolution_notes TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_incidents_building ON incidents(building_id);
CREATE INDEX IF NOT EXISTS idx_incidents_status ON incidents(status);
CREATE INDEX IF NOT EXISTS idx_incidents_type ON incidents(type);
CREATE INDEX IF NOT EXISTS idx_incidents_assigned ON incidents(assigned_to);
CREATE INDEX IF NOT EXISTS idx_incidents_review ON incidents(review_id);
-- ========= ОТЧЕТЫ ЖИТЕЛЯМ =========
CREATE TABLE IF NOT EXISTS resident_reports (
id BIGSERIAL PRIMARY KEY,
building_id VARCHAR(50) NOT NULL REFERENCES buildings(id) ON DELETE CASCADE,
month VARCHAR(20) NOT NULL,
period_start DATE NOT NULL,
period_end DATE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published')),
published_at TIMESTAMPTZ,
content JSONB, -- Структурированный контент отчета
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_resident_reports_building ON resident_reports(building_id);
CREATE INDEX IF NOT EXISTS idx_resident_reports_status ON resident_reports(status);
CREATE INDEX IF NOT EXISTS idx_resident_reports_period ON resident_reports(period_start);
-- ========= ФОТО ОТЧЕТЫ РАБОТ =========
CREATE TABLE IF NOT EXISTS work_photos (
id BIGSERIAL PRIMARY KEY,
building_id VARCHAR(50) NOT NULL REFERENCES buildings(id) ON DELETE CASCADE,
resident_report_id BIGINT REFERENCES resident_reports(id) ON DELETE SET NULL,
work_name TEXT NOT NULL,
work_date DATE NOT NULL,
description TEXT,
photo_before_url TEXT,
photo_after_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_work_photos_building ON work_photos(building_id);
CREATE INDEX IF NOT EXISTS idx_work_photos_report ON work_photos(resident_report_id);
CREATE INDEX IF NOT EXISTS idx_work_photos_date ON work_photos(work_date DESC);
-- ========= НАСТРОЙКИ ПАРСИНГА =========
CREATE TABLE IF NOT EXISTS parsing_settings (
id BIGSERIAL PRIMARY KEY,
source VARCHAR(20) NOT NULL UNIQUE CHECK (source IN ('yandex_maps', '2gis')),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
url_template TEXT,
api_key TEXT,
parsing_interval_hours INTEGER DEFAULT 24,
last_parsed_at TIMESTAMPTZ,
settings JSONB, -- Дополнительные настройки
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_parsing_settings_source ON parsing_settings(source);
-- Инициализация настроек парсинга по умолчанию
INSERT INTO parsing_settings (source, enabled, url_template, parsing_interval_hours)
VALUES
('yandex_maps', FALSE, '', 24),
('2gis', FALSE, '', 24)
ON CONFLICT (source) DO NOTHING;

View File

@@ -0,0 +1,90 @@
-- ========= ЮРИДИЧЕСКИЙ ОТДЕЛ: ДОСУДЕБНАЯ РАБОТА =========
-- Миграция для создания таблиц досудебной работы с должниками
-- Должники в юридическом отделе
CREATE TABLE IF NOT EXISTS legal_debtors (
id BIGSERIAL PRIMARY KEY,
building_id VARCHAR(50) REFERENCES buildings(id) ON DELETE SET NULL,
apartment TEXT NOT NULL,
debtor_name TEXT,
phone TEXT,
email TEXT,
address TEXT NOT NULL,
debt_amount NUMERIC(10, 2) NOT NULL,
debt_months INTEGER NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'new' CHECK (status IN ('new', 'in_progress', 'promised_payment', 'transferred_to_court', 'resolved')),
transferred_from_finance BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_legal_debtors_building ON legal_debtors(building_id);
CREATE INDEX IF NOT EXISTS idx_legal_debtors_status ON legal_debtors(status);
CREATE INDEX IF NOT EXISTS idx_legal_debtors_apartment ON legal_debtors(apartment);
CREATE INDEX IF NOT EXISTS idx_legal_debtors_created ON legal_debtors(created_at DESC);
-- Досудебная работа
CREATE TABLE IF NOT EXISTS pre_trial_work (
id BIGSERIAL PRIMARY KEY,
debtor_id BIGINT NOT NULL REFERENCES legal_debtors(id) ON DELETE CASCADE,
assigned_to TEXT, -- Кто ведет дело
status VARCHAR(20) NOT NULL DEFAULT 'new' CHECK (status IN ('new', 'in_progress', 'promised_payment', 'transferred_to_court', 'resolved')),
promised_payment_date DATE,
promised_payment_amount NUMERIC(10, 2),
transferred_to_court BOOLEAN DEFAULT FALSE,
court_case_id VARCHAR(50), -- Связь с судебным делом
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_pre_trial_work_debtor ON pre_trial_work(debtor_id);
CREATE INDEX IF NOT EXISTS idx_pre_trial_work_status ON pre_trial_work(status);
CREATE INDEX IF NOT EXISTS idx_pre_trial_work_assigned ON pre_trial_work(assigned_to);
CREATE INDEX IF NOT EXISTS idx_pre_trial_work_court_case ON pre_trial_work(court_case_id);
CREATE INDEX IF NOT EXISTS idx_pre_trial_work_promised_date ON pre_trial_work(promised_payment_date);
-- Действия (звонки, письма, визиты)
CREATE TABLE IF NOT EXISTS pre_trial_actions (
id BIGSERIAL PRIMARY KEY,
work_id BIGINT NOT NULL REFERENCES pre_trial_work(id) ON DELETE CASCADE,
action_type VARCHAR(20) NOT NULL CHECK (action_type IN ('call', 'letter', 'visit')),
action_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
performed_by TEXT NOT NULL,
result TEXT, -- Результат действия
notes TEXT,
attachments TEXT[], -- Файлы (например, фото визита)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_pre_trial_actions_work ON pre_trial_actions(work_id);
CREATE INDEX IF NOT EXISTS idx_pre_trial_actions_type ON pre_trial_actions(action_type);
CREATE INDEX IF NOT EXISTS idx_pre_trial_actions_date ON pre_trial_actions(action_date DESC);
CREATE INDEX IF NOT EXISTS idx_pre_trial_actions_performed ON pre_trial_actions(performed_by);
-- Обещанные оплаты
CREATE TABLE IF NOT EXISTS promised_payments (
id BIGSERIAL PRIMARY KEY,
work_id BIGINT NOT NULL REFERENCES pre_trial_work(id) ON DELETE CASCADE,
promised_date DATE NOT NULL,
promised_amount NUMERIC(10, 2) NOT NULL,
actual_payment_date DATE,
actual_payment_amount NUMERIC(10, 2),
is_paid BOOLEAN DEFAULT FALSE,
reminder_sent BOOLEAN DEFAULT FALSE,
reminder_date DATE,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_promised_payments_work ON promised_payments(work_id);
CREATE INDEX IF NOT EXISTS idx_promised_payments_date ON promised_payments(promised_date);
CREATE INDEX IF NOT EXISTS idx_promised_payments_paid ON promised_payments(is_paid);
CREATE INDEX IF NOT EXISTS idx_promised_payments_reminder ON promised_payments(reminder_sent, reminder_date);
-- Комментарий для документации
COMMENT ON TABLE legal_debtors IS 'Должники, переданные из финансового отдела в юридический отдел для досудебной работы';
COMMENT ON TABLE pre_trial_work IS 'Досудебная работа по взысканию задолженности с должников';
COMMENT ON TABLE pre_trial_actions IS 'Действия, выполненные в рамках досудебной работы (звонки, письма, визиты)';
COMMENT ON TABLE promised_payments IS 'Обещанные должниками оплаты с отслеживанием выполнения';

View File

@@ -0,0 +1,60 @@
-- ========= ДАННЫЕ ОТЧЕТОВ ДЛЯ ЖИТЕЛЕЙ =========
-- Одна запись на дом + период (например: дом 12, январь 2026).
-- Все собранные данные отчета сохраняются здесь.
CREATE TABLE IF NOT EXISTS resident_report_data (
id BIGSERIAL PRIMARY KEY,
building_id VARCHAR(50) NOT NULL REFERENCES buildings(id) ON DELETE CASCADE,
period_start DATE NOT NULL,
period_end DATE NOT NULL,
period_month INTEGER NOT NULL, -- 1-12
period_year INTEGER NOT NULL,
-- NPS
nps_score INTEGER NOT NULL DEFAULT 0,
nps_total_responses INTEGER NOT NULL DEFAULT 0,
nps_avg_score NUMERIC(4,2) DEFAULT 0,
nps_promoters INTEGER NOT NULL DEFAULT 0,
nps_passives INTEGER NOT NULL DEFAULT 0,
nps_detractors INTEGER NOT NULL DEFAULT 0,
-- Заявки
apps_total INTEGER NOT NULL DEFAULT 0,
apps_completed INTEGER NOT NULL DEFAULT 0,
apps_quality INTEGER NOT NULL DEFAULT 0,
-- Задачи (календарный план)
tasks_total INTEGER NOT NULL DEFAULT 0,
tasks_completed INTEGER NOT NULL DEFAULT 0,
-- Финансы
funds_collected NUMERIC(14,2) NOT NULL DEFAULT 0,
funds_spent NUMERIC(14,2) NOT NULL DEFAULT 0,
funds_balance NUMERIC(14,2) NOT NULL DEFAULT 0,
-- Долги
debt_cases_won INTEGER NOT NULL DEFAULT 0,
debt_collected NUMERIC(14,2) NOT NULL DEFAULT 0,
-- Расходы (итог и по категориям — JSON)
expenses_total NUMERIC(14,2) NOT NULL DEFAULT 0,
expenses_by_category JSONB,
-- Доп. данные в JSON (мероприятия, фото, план)
events JSONB,
work_photos JSONB,
plan_items JSONB,
-- Полный снимок отчета (как в content) — на случай расширения
snapshot JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(building_id, period_start, period_end)
);
CREATE INDEX IF NOT EXISTS idx_resident_report_data_building ON resident_report_data(building_id);
CREATE INDEX IF NOT EXISTS idx_resident_report_data_period ON resident_report_data(period_year, period_month);
COMMENT ON TABLE resident_report_data IS 'Собранные данные отчетов для жителей: дом + период (напр. дом 12, январь)';

View File

@@ -0,0 +1,42 @@
-- ========= HR: ИСТОРИЯ ЗАРПЛАТ ИЗ 1С =========
-- Таблица для хранения истории зарплат по периодам
CREATE TABLE IF NOT EXISTS employee_salary_history (
id BIGSERIAL PRIMARY KEY,
employee_id VARCHAR(50) NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
report_id BIGINT REFERENCES financial_reports(id) ON DELETE SET NULL,
-- Период
period_month INTEGER NOT NULL CHECK (period_month >= 1 AND period_month <= 12),
period_year INTEGER NOT NULL CHECK (period_year >= 2000 AND period_year <= 2100),
-- Зарплатные данные
base_salary NUMERIC(10, 2) DEFAULT 0, -- Оклад
actual_salary NUMERIC(10, 2) DEFAULT 0, -- Фактическая зарплата
bonuses NUMERIC(10, 2) DEFAULT 0, -- Премии
deductions NUMERIC(10, 2) DEFAULT 0, -- Удержания
net_salary NUMERIC(10, 2) DEFAULT 0, -- К выплате
-- Отработанное время
worked_days NUMERIC(5, 2), -- Отработано дней
worked_hours NUMERIC(6, 2), -- Отработано часов
vacation_days NUMERIC(5, 2) DEFAULT 0, -- Дни отпуска
sick_leave_days NUMERIC(5, 2) DEFAULT 0, -- Дни больничного
-- Дополнительные данные из 1С
metadata JSONB, -- Дополнительные поля из отчета
-- Служебные поля
imported_from_1c BOOLEAN DEFAULT true, -- Импортировано из 1С
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Уникальность: один сотрудник - один период
UNIQUE(employee_id, period_month, period_year)
);
CREATE INDEX IF NOT EXISTS idx_salary_history_employee ON employee_salary_history(employee_id);
CREATE INDEX IF NOT EXISTS idx_salary_history_period ON employee_salary_history(period_year, period_month);
CREATE INDEX IF NOT EXISTS idx_salary_history_report ON employee_salary_history(report_id);
-- Комментарии к таблице
COMMENT ON TABLE employee_salary_history IS 'История зарплат сотрудников по периодам, импортированная из 1С';
COMMENT ON COLUMN employee_salary_history.base_salary IS 'Оклад (базовая ставка)';
COMMENT ON COLUMN employee_salary_history.actual_salary IS 'Фактическая начисленная зарплата';
COMMENT ON COLUMN employee_salary_history.net_salary IS 'Зарплата к выплате (после всех удержаний)';
COMMENT ON COLUMN employee_salary_history.metadata IS 'Дополнительные данные из отчета 1С в формате JSON';

View File

@@ -0,0 +1,124 @@
-- ========= HR: ИНСТРУКТАЖИ И КУРСЫ (БЛОК ОБУЧЕНИЯ) =========
-- Тип обучения
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'training_type') THEN
CREATE TYPE training_type AS ENUM (
'instruction', -- Инструктаж
'course', -- Курс
'certification', -- Сертификация
'exam', -- Экзамен
'other' -- Другое
);
END IF;
END$$;
-- Категория обучения
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'training_category') THEN
CREATE TYPE training_category AS ENUM (
'safety', -- Техника безопасности
'fire_safety', -- Пожарная безопасность
'electrical', -- Электротехническая безопасность
'first_aid', -- Первая помощь
'professional', -- Профессиональное обучение
'compliance', -- Соответствие требованиям
'other' -- Другое
);
END IF;
END$$;
-- Статус прохождения обучения
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'training_status') THEN
CREATE TYPE training_status AS ENUM (
'not_started', -- Не начато
'in_progress', -- В процессе
'completed', -- Завершено
'failed', -- Не пройдено
'expired', -- Просрочено
'cancelled' -- Отменено
);
END IF;
END$$;
-- Таблица программ обучения (инструктажи и курсы)
CREATE TABLE IF NOT EXISTS training_programs (
id VARCHAR(50) PRIMARY KEY,
title TEXT NOT NULL, -- Название программы
description TEXT, -- Описание
type training_type NOT NULL, -- Тип обучения
category training_category NOT NULL, -- Категория
duration_hours NUMERIC(5, 2), -- Длительность в часах
validity_months INTEGER, -- Срок действия в месяцах (NULL = бессрочно)
is_required BOOLEAN DEFAULT false, -- Обязательное обучение
required_for_positions TEXT[], -- Должности, для которых обязательно
instructor_name TEXT, -- Инструктор/преподаватель
materials_url TEXT, -- Ссылка на материалы
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_training_programs_type ON training_programs(type);
CREATE INDEX IF NOT EXISTS idx_training_programs_category ON training_programs(category);
CREATE INDEX IF NOT EXISTS idx_training_programs_required ON training_programs(is_required);
-- Таблица прохождения обучения сотрудниками
CREATE TABLE IF NOT EXISTS employee_training (
id BIGSERIAL PRIMARY KEY,
employee_id VARCHAR(50) NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
program_id VARCHAR(50) NOT NULL REFERENCES training_programs(id) ON DELETE CASCADE,
status training_status NOT NULL DEFAULT 'not_started',
-- Даты
start_date DATE, -- Дата начала
completion_date DATE, -- Дата завершения
expiry_date DATE, -- Дата окончания срока действия
-- Результаты
score NUMERIC(5, 2), -- Оценка/балл (если есть)
passed BOOLEAN, -- Сдано/не сдано
certificate_number TEXT, -- Номер сертификата
certificate_url TEXT, -- Ссылка на сертификат
-- Дополнительная информация
notes TEXT, -- Заметки
instructor_name TEXT, -- Кто проводил (может отличаться от программы)
location TEXT, -- Место проведения
-- Служебные поля
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Уникальность: один сотрудник может проходить программу только один раз
-- (но можно пересдавать, если статус 'failed' или 'expired')
UNIQUE(employee_id, program_id)
);
CREATE INDEX IF NOT EXISTS idx_employee_training_employee ON employee_training(employee_id);
CREATE INDEX IF NOT EXISTS idx_employee_training_program ON employee_training(program_id);
CREATE INDEX IF NOT EXISTS idx_employee_training_status ON employee_training(status);
CREATE INDEX IF NOT EXISTS idx_employee_training_expiry ON employee_training(expiry_date);
-- Таблица истории прохождения (для отслеживания пересдач и обновлений)
CREATE TABLE IF NOT EXISTS employee_training_history (
id BIGSERIAL PRIMARY KEY,
employee_training_id BIGINT NOT NULL REFERENCES employee_training(id) ON DELETE CASCADE,
status training_status NOT NULL,
completion_date DATE,
score NUMERIC(5, 2),
passed BOOLEAN,
certificate_number TEXT,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_training_history_training ON employee_training_history(employee_training_id);
CREATE INDEX IF NOT EXISTS idx_training_history_date ON employee_training_history(created_at DESC);
-- Комментарии к таблицам
COMMENT ON TABLE training_programs IS 'Программы обучения: инструктажи, курсы, сертификации';
COMMENT ON TABLE employee_training IS 'Прохождение обучения сотрудниками';
COMMENT ON TABLE employee_training_history IS 'История прохождения обучения (для отслеживания пересдач)';
COMMENT ON COLUMN training_programs.validity_months IS 'Срок действия обучения в месяцах. NULL = бессрочно';
COMMENT ON COLUMN training_programs.required_for_positions IS 'Массив должностей, для которых обучение обязательно';
COMMENT ON COLUMN employee_training.expiry_date IS 'Дата окончания срока действия. Рассчитывается автоматически на основе validity_months программы';

View File

@@ -0,0 +1,66 @@
-- Миграция: source и поля для ручной карточки заявки (диспетчерская)
-- Выполнить: psql -d mkd_control_center -f migrations/add_applications_manual_fields.sql
DO $$
BEGIN
-- source: 'doma' | 'manual'
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'applications' AND column_name = 'source') THEN
ALTER TABLE applications ADD COLUMN source VARCHAR(20) NOT NULL DEFAULT 'doma';
END IF;
-- Откуда поступила заявка (Звонок, Заявка с сайта и т.д.)
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'applications' AND column_name = 'source_channel') THEN
ALTER TABLE applications ADD COLUMN source_channel TEXT;
END IF;
-- Заявка от жителя (true) / не от жителя (false)
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'applications' AND column_name = 'is_from_resident') THEN
ALTER TABLE applications ADD COLUMN is_from_resident BOOLEAN DEFAULT true;
END IF;
-- Телефон, ФИО (для не от жителя)
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'applications' AND column_name = 'contact_phone') THEN
ALTER TABLE applications ADD COLUMN contact_phone TEXT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'applications' AND column_name = 'contact_name') THEN
ALTER TABLE applications ADD COLUMN contact_name TEXT;
END IF;
-- Классификатор: место инцидента, тип работ, в чём проблема
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'applications' AND column_name = 'place_incident') THEN
ALTER TABLE applications ADD COLUMN place_incident TEXT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'applications' AND column_name = 'work_type') THEN
ALTER TABLE applications ADD COLUMN work_type TEXT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'applications' AND column_name = 'problem_detail') THEN
ALTER TABLE applications ADD COLUMN problem_detail TEXT;
END IF;
-- Чекбоксы: аварийная, платная, гарантийная
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'applications' AND column_name = 'is_emergency') THEN
ALTER TABLE applications ADD COLUMN is_emergency BOOLEAN DEFAULT false;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'applications' AND column_name = 'is_paid') THEN
ALTER TABLE applications ADD COLUMN is_paid BOOLEAN DEFAULT false;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'applications' AND column_name = 'is_warranty') THEN
ALTER TABLE applications ADD COLUMN is_warranty BOOLEAN DEFAULT false;
END IF;
-- Назначение: исполнитель, ответственный, наблюдатели
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'applications' AND column_name = 'executor_name') THEN
ALTER TABLE applications ADD COLUMN executor_name TEXT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'applications' AND column_name = 'responsible_name') THEN
ALTER TABLE applications ADD COLUMN responsible_name TEXT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'applications' AND column_name = 'observers_text') THEN
ALTER TABLE applications ADD COLUMN observers_text TEXT;
END IF;
-- Отображать заявку в мобильном приложении жителя
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'applications' AND column_name = 'show_in_app') THEN
ALTER TABLE applications ADD COLUMN show_in_app BOOLEAN DEFAULT false;
END IF;
END $$;

View File

@@ -0,0 +1,17 @@
-- Миграция для добавления массива файлов закрывающих документов в payment_invoices
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'payment_invoices'
AND column_name = 'closing_docs_files'
) THEN
ALTER TABLE payment_invoices
ADD COLUMN closing_docs_files JSONB DEFAULT '[]';
COMMENT ON COLUMN payment_invoices.closing_docs_files IS 'Список файлов закрывающих документов: [{filename, url, size, mimetype, uploadedAt, storedFilename}]';
END IF;
END $$;

View File

@@ -0,0 +1,11 @@
-- Добавить назначение "мероприятие" и привязку к pr_events
-- 1. Расширить CHECK purpose_type: добавить 'event'
ALTER TABLE payment_invoices DROP CONSTRAINT IF EXISTS payment_invoices_purpose_type_check;
ALTER TABLE payment_invoices ADD CONSTRAINT payment_invoices_purpose_type_check
CHECK (purpose_type IN ('building', 'district', 'legal', 'office', 'hr', 'other', 'event'));
-- 2. Колонка привязки счёта к мероприятию
ALTER TABLE payment_invoices ADD COLUMN IF NOT EXISTS purpose_event_id BIGINT REFERENCES pr_events(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_payment_invoices_purpose_event ON payment_invoices(purpose_event_id) WHERE purpose_event_id IS NOT NULL;
COMMENT ON COLUMN payment_invoices.purpose_event_id IS 'ID мероприятия (pr_events), если purpose_type = event';

View File

@@ -0,0 +1,3 @@
-- Ожидаемая дата возврата из ремонта
ALTER TABLE office_repair_requests
ADD COLUMN IF NOT EXISTS expected_return_date DATE;

View File

@@ -0,0 +1,4 @@
-- Добавить поле для изображения в pr_scheduled_posts
ALTER TABLE pr_scheduled_posts ADD COLUMN IF NOT EXISTS image_url TEXT;
COMMENT ON COLUMN pr_scheduled_posts.image_url IS 'URL изображения для поста';

View File

@@ -0,0 +1,8 @@
-- Миграция: добавление поля inventory в таблицу districts
-- Склад участка хранится в JSONB поле для гибкости структуры
ALTER TABLE districts
ADD COLUMN IF NOT EXISTS inventory JSONB DEFAULT '[]'::jsonb;
-- Создаем индекс для быстрого поиска по inventory (опционально, если нужно)
-- CREATE INDEX IF NOT EXISTS idx_districts_inventory ON districts USING GIN (inventory);

View File

@@ -0,0 +1,62 @@
-- Миграция для добавления полей item_type, service_items, material_items в таблицу payment_invoices
-- (если таблица уже существует без этих полей)
-- Проверяем и добавляем item_type
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'payment_invoices'
AND column_name = 'item_type'
) THEN
ALTER TABLE payment_invoices
ADD COLUMN item_type VARCHAR(20) NOT NULL DEFAULT 'service'
CHECK (item_type IN ('service', 'materials'));
COMMENT ON COLUMN payment_invoices.item_type IS 'Тип предмета счета: service (услуга) или materials (ТМЦ)';
END IF;
END $$;
-- Проверяем и добавляем service_items
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'payment_invoices'
AND column_name = 'service_items'
) THEN
ALTER TABLE payment_invoices
ADD COLUMN service_items JSONB DEFAULT '[]';
COMMENT ON COLUMN payment_invoices.service_items IS 'Список услуг: [{name, amount}]';
END IF;
END $$;
-- Проверяем и добавляем material_items
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'payment_invoices'
AND column_name = 'material_items'
) THEN
ALTER TABLE payment_invoices
ADD COLUMN material_items JSONB DEFAULT '[]';
COMMENT ON COLUMN payment_invoices.material_items IS 'Список ТМЦ: [{name, quantity, unit, pricePerUnit, amount}]';
END IF;
END $$;
-- Делаем service_description необязательным (для обратной совместимости)
DO $$
BEGIN
ALTER TABLE payment_invoices
ALTER COLUMN service_description DROP NOT NULL;
EXCEPTION
WHEN OTHERS THEN
-- Игнорируем ошибку, если колонка уже не NOT NULL
NULL;
END $$;

View File

@@ -0,0 +1,7 @@
-- Дополнительные поля журнала отключений (категория, тип работ, что сказать жителю и т.д.)
ALTER TABLE outages ADD COLUMN IF NOT EXISTS author_name VARCHAR(255);
ALTER TABLE outages ADD COLUMN IF NOT EXISTS category VARCHAR(100);
ALTER TABLE outages ADD COLUMN IF NOT EXISTS problem_detail VARCHAR(255);
ALTER TABLE outages ADD COLUMN IF NOT EXISTS work_type VARCHAR(50);
ALTER TABLE outages ADD COLUMN IF NOT EXISTS resident_message TEXT;
ALTER TABLE outages ADD COLUMN IF NOT EXISTS generate_news BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,9 @@
-- Номер платежки и признак оплаты наличными
ALTER TABLE payment_invoices ADD COLUMN IF NOT EXISTS payment_ref TEXT;
ALTER TABLE payment_invoices ADD COLUMN IF NOT EXISTS is_cash BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE payment_invoices ADD COLUMN IF NOT EXISTS postponed_date DATE;
ALTER TABLE payment_invoices ADD COLUMN IF NOT EXISTS cancel_reason TEXT;
COMMENT ON COLUMN payment_invoices.payment_ref IS 'Номер платежного поручения / платежки';
COMMENT ON COLUMN payment_invoices.is_cash IS 'Оплата наличными';
COMMENT ON COLUMN payment_invoices.postponed_date IS 'Дата переноса (при отложении)';
COMMENT ON COLUMN payment_invoices.cancel_reason IS 'Причина отмены';

View File

@@ -0,0 +1,6 @@
-- Связь счета с пунктом плана работ (план/факт)
ALTER TABLE payment_invoices ADD COLUMN IF NOT EXISTS plan_item_id TEXT;
ALTER TABLE payment_invoices ADD COLUMN IF NOT EXISTS plan_item_building_id TEXT;
COMMENT ON COLUMN payment_invoices.plan_item_id IS 'ID пункта плана работ (building.annualPlan[].id)';
COMMENT ON COLUMN payment_invoices.plan_item_building_id IS 'ID дома плана (building.id)';
CREATE INDEX IF NOT EXISTS idx_payment_invoices_plan_item ON payment_invoices(plan_item_building_id, plan_item_id) WHERE plan_item_id IS NOT NULL;

View File

@@ -0,0 +1,8 @@
-- Место проведения для мероприятий "для жителей": участок или дома отдельно
ALTER TABLE pr_events ADD COLUMN IF NOT EXISTS location_place_type VARCHAR(20) CHECK (location_place_type IN ('district', 'buildings'));
ALTER TABLE pr_events ADD COLUMN IF NOT EXISTS location_district_id VARCHAR(50) REFERENCES districts(id) ON DELETE SET NULL;
ALTER TABLE pr_events ADD COLUMN IF NOT EXISTS location_building_ids JSONB DEFAULT '[]';
COMMENT ON COLUMN pr_events.location_place_type IS 'Для жителей: district = участок, buildings = дома отдельно';
COMMENT ON COLUMN pr_events.location_district_id IS 'ID участка, если location_place_type = district';
COMMENT ON COLUMN pr_events.location_building_ids IS 'Массив ID домов, если location_place_type = buildings';

View File

@@ -0,0 +1,5 @@
-- Новые статусы заявок на ремонт техники
ALTER TYPE repair_request_status ADD VALUE IF NOT EXISTS 'search_contractor';
ALTER TYPE repair_request_status ADD VALUE IF NOT EXISTS 'waiting_delivery';
ALTER TYPE repair_request_status ADD VALUE IF NOT EXISTS 'taken_for_repair';
ALTER TYPE repair_request_status ADD VALUE IF NOT EXISTS 'self_repair';

View File

@@ -0,0 +1,9 @@
-- Новый статус: Договорились с подрядчиком
ALTER TYPE repair_request_status ADD VALUE IF NOT EXISTS 'agreed_with_contractor';
-- Поля для статусов: ожидание поставки, увезли на ремонт, договорились
ALTER TABLE office_repair_requests ADD COLUMN IF NOT EXISTS waiting_delivery_deadline TEXT;
ALTER TABLE office_repair_requests ADD COLUMN IF NOT EXISTS waiting_delivery_contacts TEXT;
ALTER TABLE office_repair_requests ADD COLUMN IF NOT EXISTS taken_for_repair_deadline TEXT;
ALTER TABLE office_repair_requests ADD COLUMN IF NOT EXISTS taken_for_repair_contacts TEXT;
ALTER TABLE office_repair_requests ADD COLUMN IF NOT EXISTS agreed_contractor_price NUMERIC(10, 2);

View File

@@ -0,0 +1,12 @@
-- Миграция: добавление поля report_type в таблицу financial_reports
-- Типы отчетов: debtors (должники), balance_sheet (оборотная сальдовая ведомость), other (другие)
ALTER TABLE financial_reports
ADD COLUMN IF NOT EXISTS report_type VARCHAR(50) DEFAULT 'other'
CHECK (report_type IN ('debtors', 'balance_sheet', 'other'));
-- Создаем индекс для быстрого поиска по типу отчета
CREATE INDEX IF NOT EXISTS idx_financial_reports_type ON financial_reports(report_type);
-- Комментарий к колонке
COMMENT ON COLUMN financial_reports.report_type IS 'Тип финансового отчета: debtors (должники), balance_sheet (оборотная сальдовая ведомость), other (другие)';

View File

@@ -0,0 +1,7 @@
-- Расширение report_type: добавление balance_sheet_76 (ОСВ по счёту 76.06 — лицевые счета)
-- Имя ограничения в PostgreSQL при ADD COLUMN CHECK часто: financial_reports_report_type_check
ALTER TABLE financial_reports DROP CONSTRAINT IF EXISTS financial_reports_report_type_check;
ALTER TABLE financial_reports ADD CONSTRAINT financial_reports_report_type_check
CHECK (report_type IN ('debtors', 'balance_sheet', 'balance_sheet_76', 'other'));
COMMENT ON COLUMN financial_reports.report_type IS 'Тип отчета: debtors, balance_sheet (ОСВ 20), balance_sheet_76 (ОСВ 76 — лицевые счета), other';

View File

@@ -0,0 +1,10 @@
-- Добавить scheduled_date в pr_post_topics (дата планируемой публикации)
ALTER TABLE pr_post_topics ADD COLUMN IF NOT EXISTS scheduled_date DATE;
-- Если scheduled_date пустое, заполнить из month (первый день месяца)
UPDATE pr_post_topics SET scheduled_date = (month || '-01')::date WHERE scheduled_date IS NULL;
-- Создать индекс
CREATE INDEX IF NOT EXISTS idx_pr_post_topics_scheduled_date ON pr_post_topics(scheduled_date);
COMMENT ON COLUMN pr_post_topics.scheduled_date IS 'Дата планируемой публикации поста по этой теме';

View File

@@ -0,0 +1,5 @@
-- Привязка фото отчёта (до/после) к задаче
ALTER TABLE work_photos
ADD COLUMN IF NOT EXISTS task_id VARCHAR(50);
CREATE INDEX IF NOT EXISTS idx_work_photos_task ON work_photos(task_id);

View File

@@ -0,0 +1,30 @@
-- Миграция: расширение профиля пользователя (план User Profile System)
-- Добавляет поля в portal_users и создает таблицу user_preferences
-- 1. Добавление полей в portal_users
ALTER TABLE portal_users ADD COLUMN IF NOT EXISTS phone VARCHAR(20);
ALTER TABLE portal_users ADD COLUMN IF NOT EXISTS given_name TEXT;
ALTER TABLE portal_users ADD COLUMN IF NOT EXISTS family_name TEXT;
ALTER TABLE portal_users ADD COLUMN IF NOT EXISTS birth_date DATE;
ALTER TABLE portal_users ADD COLUMN IF NOT EXISTS language VARCHAR(10) DEFAULT 'ru';
ALTER TABLE portal_users ADD COLUMN IF NOT EXISTS theme VARCHAR(20) DEFAULT 'light';
ALTER TABLE portal_users ADD COLUMN IF NOT EXISTS notification_email BOOLEAN DEFAULT true;
ALTER TABLE portal_users ADD COLUMN IF NOT EXISTS notification_push BOOLEAN DEFAULT true;
ALTER TABLE portal_users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN DEFAULT false;
ALTER TABLE portal_users ADD COLUMN IF NOT EXISTS last_login TIMESTAMPTZ;
ALTER TABLE portal_users ADD COLUMN IF NOT EXISTS password_changed_at TIMESTAMPTZ;
-- 2. Создание таблицы user_preferences для расширяемых настроек
CREATE TABLE IF NOT EXISTS user_preferences (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES portal_users(id) ON DELETE CASCADE,
key TEXT NOT NULL,
value JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id, key)
);
CREATE INDEX IF NOT EXISTS idx_user_preferences_user_id ON user_preferences(user_id);
COMMENT ON TABLE user_preferences IS 'Расширяемые настройки и предпочтения пользователя';

View File

@@ -0,0 +1,25 @@
-- Комментарии к заявкам (внутренние и с жителем)
CREATE TABLE IF NOT EXISTS application_comments (
id BIGSERIAL PRIMARY KEY,
application_id BIGINT NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
author_name TEXT NOT NULL,
type VARCHAR(20) NOT NULL DEFAULT 'internal',
text TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_application_comments_app ON application_comments(application_id);
CREATE INDEX IF NOT EXISTS idx_application_comments_type ON application_comments(type);
-- История изменений заявки
CREATE TABLE IF NOT EXISTS application_history (
id BIGSERIAL PRIMARY KEY,
application_id BIGINT NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
changed_by TEXT NOT NULL,
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
field_name TEXT NOT NULL,
old_value TEXT,
new_value TEXT
);
CREATE INDEX IF NOT EXISTS idx_application_history_app ON application_history(application_id);

View File

@@ -0,0 +1,21 @@
-- Лицевые счета домов: отдельная таблица, чтобы не терять их при обновлении buildings.data
CREATE TABLE IF NOT EXISTS building_personal_accounts (
id VARCHAR(255) PRIMARY KEY,
building_id VARCHAR(255) NOT NULL REFERENCES buildings(id) ON DELETE CASCADE,
data JSONB NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_building_personal_accounts_building_id ON building_personal_accounts(building_id);
COMMENT ON TABLE building_personal_accounts IS 'Лицевые счета (собственники, прописанные, приборы учёта) — вынесены из buildings.data для защиты от перезаписи';
-- Перенос существующих счетов из buildings.data->accounts в новую таблицу (идемпотентно: ON CONFLICT DO NOTHING)
INSERT INTO building_personal_accounts (id, building_id, data)
SELECT
COALESCE(acc->>'id', b.id || '-acc-migrated-' || t.ord),
b.id,
CASE WHEN (acc ? 'id') AND (acc->>'id' IS NOT NULL) AND (acc->>'id' <> '') THEN acc ELSE acc || jsonb_build_object('id', COALESCE(acc->>'id', b.id || '-acc-migrated-' || t.ord)) END
FROM buildings b,
LATERAL jsonb_array_elements(
CASE WHEN jsonb_typeof(COALESCE(b.data->'accounts', '[]'::jsonb)) = 'array' THEN COALESCE(b.data->'accounts', '[]'::jsonb) ELSE '[]'::jsonb END
) WITH ORDINALITY AS t(acc, ord)
ON CONFLICT (id) DO NOTHING;

View File

@@ -0,0 +1,19 @@
-- Новости компании: блок на сводке и реестр в офисе
CREATE TABLE IF NOT EXISTS company_news (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
body TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'pending', 'published')),
created_by BIGINT REFERENCES portal_users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
published_at TIMESTAMPTZ,
notify_departments JSONB DEFAULT '[]',
notify_employee_ids JSONB DEFAULT '[]'
);
CREATE INDEX IF NOT EXISTS idx_company_news_status ON company_news(status);
CREATE INDEX IF NOT EXISTS idx_company_news_created_at ON company_news(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_company_news_published_at ON company_news(published_at DESC NULLS LAST) WHERE status = 'published';
COMMENT ON TABLE company_news IS 'Новости компании: черновики и публикации с уведомлением по отделам/сотрудникам';

View File

@@ -0,0 +1,18 @@
-- Таблица строк отчёта по задолженности (один отчёт — много строк по лицевым счетам)
CREATE TABLE IF NOT EXISTS debtor_report_data (
id BIGSERIAL PRIMARY KEY,
report_id BIGINT NOT NULL REFERENCES financial_reports(id) ON DELETE CASCADE,
row_index INTEGER NOT NULL,
account TEXT NOT NULL,
responsible_name TEXT,
object_address TEXT,
months_debt INTEGER,
total_debt NUMERIC(14, 2) NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_debtor_report_data_report_id ON debtor_report_data(report_id);
CREATE INDEX IF NOT EXISTS idx_debtor_report_data_account ON debtor_report_data(account);
CREATE INDEX IF NOT EXISTS idx_debtor_report_data_total_debt ON debtor_report_data(total_debt);
COMMENT ON TABLE debtor_report_data IS 'Строки отчёта по задолженности: лицевой счёт, ФИО, адрес, месяцы долга, сумма';

View File

@@ -0,0 +1,18 @@
-- Связь сотрудник — участки (многие ко многим): один сотрудник может быть назначен на несколько участков
CREATE TABLE IF NOT EXISTS employee_districts (
employee_id VARCHAR(50) NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
district_id VARCHAR(50) NOT NULL REFERENCES districts(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (employee_id, district_id)
);
CREATE INDEX IF NOT EXISTS idx_employee_districts_employee ON employee_districts(employee_id);
CREATE INDEX IF NOT EXISTS idx_employee_districts_district ON employee_districts(district_id);
COMMENT ON TABLE employee_districts IS 'Назначения сотрудников на участки; один сотрудник может быть на нескольких участках';
-- Перенос существующих назначений из employees.assigned_district_id
INSERT INTO employee_districts (employee_id, district_id)
SELECT id, assigned_district_id
FROM employees
WHERE assigned_district_id IS NOT NULL AND assigned_district_id <> ''
ON CONFLICT (employee_id, district_id) DO NOTHING;

View File

@@ -0,0 +1,14 @@
-- Зоны ответственности: привязка сотрудников к подразделам модулей (уведомления, эффективность).
-- Запуск: psql -U user -d your_db -f create_employee_responsibility.sql
CREATE TABLE IF NOT EXISTS employee_responsibility (
id BIGSERIAL PRIMARY KEY,
employee_id VARCHAR(50) NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
section VARCHAR(50) NOT NULL,
sub_id VARCHAR(50) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(employee_id, section, sub_id)
);
CREATE INDEX IF NOT EXISTS idx_employee_responsibility_employee ON employee_responsibility(employee_id);
CREATE INDEX IF NOT EXISTS idx_employee_responsibility_zone ON employee_responsibility(section, sub_id);

View File

@@ -0,0 +1,42 @@
-- Справочник статей расходов
-- Категории расходов
CREATE TABLE IF NOT EXISTS expense_categories (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
code VARCHAR(50) UNIQUE, -- Код категории (опционально)
description TEXT,
sort_order INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Статьи расходов внутри категорий
CREATE TABLE IF NOT EXISTS expense_items (
id BIGSERIAL PRIMARY KEY,
category_id BIGINT NOT NULL REFERENCES expense_categories(id) ON DELETE CASCADE,
name TEXT NOT NULL,
code VARCHAR(50), -- Код статьи (опционально)
description TEXT,
parent_item_id BIGINT REFERENCES expense_items(id) ON DELETE CASCADE, -- Для вложенных статей
sort_order INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(category_id, name)
);
-- Индексы
CREATE INDEX IF NOT EXISTS idx_expense_items_category ON expense_items(category_id);
CREATE INDEX IF NOT EXISTS idx_expense_items_parent ON expense_items(parent_item_id);
CREATE INDEX IF NOT EXISTS idx_expense_items_active ON expense_items(is_active);
CREATE INDEX IF NOT EXISTS idx_expense_categories_active ON expense_categories(is_active);
-- Связь данных отчета со справочником
-- Добавляем поле для хранения ID статьи из справочника
ALTER TABLE building_financial_data
ADD COLUMN IF NOT EXISTS expense_item_mappings JSONB DEFAULT '{}';
COMMENT ON TABLE expense_categories IS 'Справочник категорий расходов';
COMMENT ON TABLE expense_items IS 'Справочник статей расходов внутри категорий';
COMMENT ON COLUMN building_financial_data.expense_item_mappings IS 'Маппинг расходов на статьи из справочника: { "item_id": amount, ... }';

View File

@@ -0,0 +1,16 @@
-- Счета: банки и наличка (кошельки) для отображения остатков в календаре
CREATE TABLE IF NOT EXISTS finance_accounts (
id BIGSERIAL PRIMARY KEY,
type VARCHAR(20) NOT NULL CHECK (type IN ('bank', 'cash')),
name TEXT NOT NULL,
balance NUMERIC(15, 2) NOT NULL DEFAULT 0,
currency VARCHAR(10) NOT NULL DEFAULT 'RUB',
sort_order INTEGER DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_finance_accounts_type ON finance_accounts(type);
CREATE INDEX IF NOT EXISTS idx_finance_accounts_active ON finance_accounts(is_active);
COMMENT ON TABLE finance_accounts IS 'Банковские счета и кошельки (наличка) для отображения остатков в календаре оплат';

View File

@@ -0,0 +1,21 @@
-- История перемещений и событий по оборудованию (закупка, выдача, перемещения, ремонты, списание)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'office_equipment_history_type') THEN
CREATE TYPE office_equipment_history_type AS ENUM ('purchase', 'issue', 'transfer', 'repair', 'write_off');
END IF;
END$$;
CREATE TABLE IF NOT EXISTS office_equipment_history (
id BIGSERIAL PRIMARY KEY,
equipment_id BIGINT NOT NULL REFERENCES office_equipment(id) ON DELETE CASCADE,
event_type office_equipment_history_type NOT NULL,
event_date DATE NOT NULL,
assigned_to TEXT,
assigned_from TEXT,
reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_equipment_history_equipment ON office_equipment_history(equipment_id);
CREATE INDEX IF NOT EXISTS idx_equipment_history_date ON office_equipment_history(event_date DESC);

View File

@@ -0,0 +1,16 @@
-- Журнал отключений (вода, свет и т.д.) по домам
CREATE TABLE IF NOT EXISTS outages (
id BIGSERIAL PRIMARY KEY,
building_id VARCHAR(50) NOT NULL REFERENCES buildings(id) ON DELETE CASCADE,
start_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
end_at TIMESTAMPTZ,
type VARCHAR(50),
description TEXT,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_outages_building ON outages(building_id);
CREATE INDEX IF NOT EXISTS idx_outages_active ON outages(active);
CREATE INDEX IF NOT EXISTS idx_outages_start_at ON outages(start_at);

View File

@@ -0,0 +1,28 @@
-- Записи платежного календаря (расходы и поступления, по счету / без счета / наличка)
CREATE TABLE IF NOT EXISTS payment_calendar_entries (
id BIGSERIAL PRIMARY KEY,
direction VARCHAR(20) NOT NULL CHECK (direction IN ('outgoing', 'incoming')),
type VARCHAR(20) NOT NULL CHECK (type IN ('invoice', 'manual', 'cash')),
payment_invoice_id BIGINT REFERENCES payment_invoices(id) ON DELETE SET NULL,
category TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
amount NUMERIC(15, 2) NOT NULL,
scheduled_date DATE NOT NULL,
payment_date DATE,
probability VARCHAR(20) NOT NULL DEFAULT 'confirmed' CHECK (probability IN ('confirmed', 'high', 'medium', 'low')),
currency VARCHAR(10) NOT NULL DEFAULT 'RUB',
is_cash BOOLEAN NOT NULL DEFAULT FALSE,
contractor_name TEXT NOT NULL DEFAULT '',
notes TEXT,
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_payment_calendar_entries_direction ON payment_calendar_entries(direction);
CREATE INDEX IF NOT EXISTS idx_payment_calendar_entries_type ON payment_calendar_entries(type);
CREATE INDEX IF NOT EXISTS idx_payment_calendar_entries_scheduled_date ON payment_calendar_entries(scheduled_date);
CREATE INDEX IF NOT EXISTS idx_payment_calendar_entries_payment_date ON payment_calendar_entries(payment_date);
CREATE INDEX IF NOT EXISTS idx_payment_calendar_entries_payment_invoice_id ON payment_calendar_entries(payment_invoice_id);
COMMENT ON TABLE payment_calendar_entries IS 'Записи платежного календаря: расходы и поступления (по счету, без счета, наличка)';

View File

@@ -0,0 +1,18 @@
-- Справочник статей доходов и расходов для платежного календаря
CREATE TABLE IF NOT EXISTS payment_categories (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
code VARCHAR(50),
direction VARCHAR(20) NOT NULL CHECK (direction IN ('income', 'expense')),
parent_id BIGINT REFERENCES payment_categories(id) ON DELETE SET NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_payment_categories_direction ON payment_categories(direction);
CREATE INDEX IF NOT EXISTS idx_payment_categories_parent ON payment_categories(parent_id);
CREATE INDEX IF NOT EXISTS idx_payment_categories_active ON payment_categories(is_active);
COMMENT ON TABLE payment_categories IS 'Справочник статей доходов и расходов для платежного календаря';

View File

@@ -0,0 +1,82 @@
-- Миграция для создания таблицы счетов на оплату
CREATE TABLE IF NOT EXISTS payment_invoices (
id BIGSERIAL PRIMARY KEY,
invoice_number TEXT UNIQUE NOT NULL,
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Назначение счета
purpose_type VARCHAR(50) NOT NULL CHECK (purpose_type IN ('building', 'district', 'legal', 'office', 'hr', 'other')),
purpose_building_ids JSONB DEFAULT '[]', -- массив ID домов
purpose_district_ids JSONB DEFAULT '[]', -- массив ID участков
purpose_description TEXT, -- описание для 'other'
-- Формат оплаты
payment_format VARCHAR(20) NOT NULL CHECK (payment_format IN ('prepayment', 'postpayment', 'advance')),
-- Тип предмета счета (услуга или ТМЦ)
item_type VARCHAR(20) NOT NULL DEFAULT 'service' CHECK (item_type IN ('service', 'materials')),
-- Информация о подрядчике
contractor_name TEXT NOT NULL,
contractor_inn TEXT,
-- Описание услуги или ТМЦ (для обратной совместимости)
service_description TEXT,
-- Список услуг (если item_type = 'service')
service_items JSONB DEFAULT '[]',
-- Список ТМЦ (если item_type = 'materials')
material_items JSONB DEFAULT '[]',
total_amount NUMERIC(15, 2) NOT NULL,
-- Распределение суммы
distribution_method VARCHAR(20) CHECK (distribution_method IN ('equal', 'by_area', 'manual')),
distribution_data JSONB DEFAULT '{}', -- детали распределения по домам/участкам
-- Workflow статусы
status VARCHAR(30) NOT NULL DEFAULT 'draft' CHECK (status IN (
'draft',
'pending_manager_approval',
'pending_finance_manager_approval',
'approved',
'scheduled',
'paid',
'postponed',
'cancelled',
'rejected',
'completed'
)),
current_approver_role VARCHAR(50), -- текущий этап согласования
approval_history JSONB DEFAULT '[]', -- история согласований
-- Отклонение
rejection_reason TEXT,
-- Даты
scheduled_date DATE, -- дата в графике платежей
payment_date DATE, -- фактическая дата оплаты
-- Статусы выполнения
is_completed BOOLEAN DEFAULT FALSE, -- для постоплаты - выполнено ли
closing_docs_received BOOLEAN DEFAULT FALSE, -- закрывающие документы получены
-- Дополнительно
notes TEXT,
file_urls JSONB DEFAULT '[]' -- массив путей к файлам счета
);
-- Индексы для быстрого поиска
CREATE INDEX IF NOT EXISTS idx_payment_invoices_status ON payment_invoices(status);
CREATE INDEX IF NOT EXISTS idx_payment_invoices_created_by ON payment_invoices(created_by);
CREATE INDEX IF NOT EXISTS idx_payment_invoices_purpose_type ON payment_invoices(purpose_type);
CREATE INDEX IF NOT EXISTS idx_payment_invoices_payment_format ON payment_invoices(payment_format);
CREATE INDEX IF NOT EXISTS idx_payment_invoices_scheduled_date ON payment_invoices(scheduled_date);
CREATE INDEX IF NOT EXISTS idx_payment_invoices_invoice_number ON payment_invoices(invoice_number);
-- Комментарии к таблице и колонкам
COMMENT ON TABLE payment_invoices IS 'Счета на оплату с workflow согласования';
COMMENT ON COLUMN payment_invoices.purpose_type IS 'Назначение: building, district, legal, office, hr, other';
COMMENT ON COLUMN payment_invoices.payment_format IS 'Формат оплаты: prepayment, postpayment, advance';
COMMENT ON COLUMN payment_invoices.distribution_method IS 'Метод распределения: equal, by_area, manual';
COMMENT ON COLUMN payment_invoices.approval_history IS 'История согласований: [{role, userId, action, date, comment}]';

View File

@@ -0,0 +1,12 @@
-- Справочник должностей (для панели управления и выбора при создании сотрудника)
CREATE TABLE IF NOT EXISTS positions (
id VARCHAR(50) PRIMARY KEY,
name TEXT NOT NULL,
is_managerial BOOLEAN NOT NULL DEFAULT FALSE,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_positions_sort ON positions(sort_order);
COMMENT ON TABLE positions IS 'Справочник должностей; is_managerial — руководящая должность';

View File

@@ -0,0 +1,19 @@
-- PR Привлечение: действия, которыми привлекали людей (рассылка, мероприятие, пост и т.д.)
CREATE TABLE IF NOT EXISTS pr_attraction_actions (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
channel_id BIGINT REFERENCES pr_smm_channels(id) ON DELETE SET NULL,
action_type VARCHAR(20) NOT NULL CHECK (action_type IN ('mailing', 'event', 'post', 'other')),
action_date DATE NOT NULL DEFAULT CURRENT_DATE,
new_subscribers_attributed INT,
event_id BIGINT REFERENCES pr_events(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT
);
CREATE INDEX IF NOT EXISTS idx_pr_attraction_actions_channel ON pr_attraction_actions(channel_id);
CREATE INDEX IF NOT EXISTS idx_pr_attraction_actions_type ON pr_attraction_actions(action_type);
CREATE INDEX IF NOT EXISTS idx_pr_attraction_actions_date ON pr_attraction_actions(action_date DESC);
COMMENT ON TABLE pr_attraction_actions IS 'Действия привлечения: рассылка, мероприятие, пост и т.д., с приростом подписчиков';

View File

@@ -0,0 +1,52 @@
-- PR Мероприятия: pr_events, pr_event_assignees, pr_event_photos
-- Таблица мероприятий
CREATE TABLE IF NOT EXISTS pr_events (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
date DATE NOT NULL,
type VARCHAR(20) NOT NULL CHECK (type IN ('resident', 'internal')),
category VARCHAR(20) NOT NULL CHECK (category IN ('holiday', 'eco', 'sport', 'training', 'meeting')),
status VARCHAR(20) NOT NULL DEFAULT 'planned' CHECK (status IN ('planned', 'in_progress', 'completed', 'canceled')),
location TEXT,
location_type VARCHAR(20) CHECK (location_type IN ('building', 'office')),
location_building_id VARCHAR(50) REFERENCES buildings(id) ON DELETE SET NULL,
attendees_count INTEGER NOT NULL DEFAULT 0,
budget NUMERIC(15, 2),
short_plan TEXT,
announcement TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT
);
CREATE INDEX IF NOT EXISTS idx_pr_events_date ON pr_events(date DESC);
CREATE INDEX IF NOT EXISTS idx_pr_events_status ON pr_events(status);
CREATE INDEX IF NOT EXISTS idx_pr_events_type ON pr_events(type);
CREATE INDEX IF NOT EXISTS idx_pr_events_location_building ON pr_events(location_building_id) WHERE location_building_id IS NOT NULL;
-- Сотрудники, назначенные на помощь по мероприятию
CREATE TABLE IF NOT EXISTS pr_event_assignees (
event_id BIGINT NOT NULL REFERENCES pr_events(id) ON DELETE CASCADE,
employee_id VARCHAR(50) NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
PRIMARY KEY (event_id, employee_id)
);
CREATE INDEX IF NOT EXISTS idx_pr_event_assignees_event ON pr_event_assignees(event_id);
CREATE INDEX IF NOT EXISTS idx_pr_event_assignees_employee ON pr_event_assignees(employee_id);
-- Фотоотчёт по мероприятию (привязка к дому/офису для внутренних)
CREATE TABLE IF NOT EXISTS pr_event_photos (
id BIGSERIAL PRIMARY KEY,
event_id BIGINT NOT NULL REFERENCES pr_events(id) ON DELETE CASCADE,
photo_url TEXT NOT NULL,
caption TEXT,
location_type VARCHAR(20) CHECK (location_type IN ('building', 'office')),
location_building_id VARCHAR(50) REFERENCES buildings(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_pr_event_photos_event ON pr_event_photos(event_id);
COMMENT ON TABLE pr_events IS 'Мероприятия PR: для жителей и внутренние';
COMMENT ON TABLE pr_event_assignees IS 'Сотрудники для помощи по мероприятию';
COMMENT ON TABLE pr_event_photos IS 'Фотоотчёт по мероприятию';

View File

@@ -0,0 +1,21 @@
-- PR График публикации (календарь тем для постов)
CREATE TABLE IF NOT EXISTS pr_post_topics (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
scheduled_date DATE, -- дата планируемой публикации (может быть NULL для старых записей)
month VARCHAR(7) NOT NULL, -- формат YYYY-MM для фильтрации
status VARCHAR(20) NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'pending_approval', 'approved', 'rejected')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT,
approved_at TIMESTAMPTZ,
approved_by TEXT,
rejection_reason TEXT,
created_at_month VARCHAR(7) GENERATED ALWAYS AS (TO_CHAR(created_at, 'YYYY-MM')) STORED
);
CREATE INDEX IF NOT EXISTS idx_pr_post_topics_month ON pr_post_topics(month DESC);
CREATE INDEX IF NOT EXISTS idx_pr_post_topics_scheduled_date ON pr_post_topics(scheduled_date);
CREATE INDEX IF NOT EXISTS idx_pr_post_topics_status ON pr_post_topics(status);
COMMENT ON TABLE pr_post_topics IS 'График публикации (календарь тем) - план публикаций на месяц. По этим темам создаются посты с контентом.';

View File

@@ -0,0 +1,23 @@
-- PR Отложенные посты (для одобрения перед публикацией)
CREATE TABLE IF NOT EXISTS pr_scheduled_posts (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
channel_ids JSONB DEFAULT '[]', -- массив ID каналов из pr_smm_channels
scheduled_at TIMESTAMPTZ NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'pending_approval', 'approved', 'rejected', 'edited', 'published')),
topic_id BIGINT REFERENCES pr_post_topics(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT,
approved_at TIMESTAMPTZ,
approved_by TEXT,
rejection_reason TEXT,
edited_content TEXT, -- если статус edited, здесь новая версия
published_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_pr_scheduled_posts_scheduled ON pr_scheduled_posts(scheduled_at DESC);
CREATE INDEX IF NOT EXISTS idx_pr_scheduled_posts_status ON pr_scheduled_posts(status);
CREATE INDEX IF NOT EXISTS idx_pr_scheduled_posts_topic ON pr_scheduled_posts(topic_id);
COMMENT ON TABLE pr_scheduled_posts IS 'Отложенные посты для одобрения перед публикацией';

View File

@@ -0,0 +1,28 @@
-- PR SMM: каналы и снимки подписчиков
-- Каналы (Telegram, VK, WhatsApp и др.)
CREATE TABLE IF NOT EXISTS pr_smm_channels (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
type VARCHAR(20) NOT NULL CHECK (type IN ('tg', 'vk', 'wa', 'other')),
url TEXT,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_pr_smm_channels_sort ON pr_smm_channels(sort_order);
-- Снимки количества подписчиков по дате (история фиксаций)
CREATE TABLE IF NOT EXISTS pr_smm_subscriber_snapshots (
id BIGSERIAL PRIMARY KEY,
channel_id BIGINT NOT NULL REFERENCES pr_smm_channels(id) ON DELETE CASCADE,
subscribers_count INT NOT NULL,
recorded_at DATE NOT NULL DEFAULT CURRENT_DATE,
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_pr_smm_snapshots_channel ON pr_smm_subscriber_snapshots(channel_id);
CREATE INDEX IF NOT EXISTS idx_pr_smm_snapshots_recorded ON pr_smm_subscriber_snapshots(recorded_at DESC);
COMMENT ON TABLE pr_smm_channels IS 'SMM-каналы УК: Telegram, VK, WhatsApp и др.';
COMMENT ON TABLE pr_smm_subscriber_snapshots IS 'Фиксации количества подписчиков по дате для истории';

View File

@@ -0,0 +1,33 @@
-- Таблица строк ОСВ по счёту 76.06 (лицевые счета жителей)
CREATE TABLE IF NOT EXISTS report_76_rows (
id BIGSERIAL PRIMARY KEY,
report_id BIGINT NOT NULL REFERENCES financial_reports(id) ON DELETE CASCADE,
row_index INTEGER NOT NULL,
account_label TEXT NOT NULL,
account_ls TEXT,
saldo_start_debet NUMERIC(15, 2) DEFAULT 0,
turnover_debet NUMERIC(15, 2) DEFAULT 0,
turnover_credit NUMERIC(15, 2) DEFAULT 0,
saldo_end_debet NUMERIC(15, 2) DEFAULT 0,
saldo_end_credit NUMERIC(15, 2) DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_report_76_rows_report_id ON report_76_rows(report_id);
COMMENT ON TABLE report_76_rows IS 'Строки ОСВ по счёту 76.06 — лицевые счета (жители) из загруженного отчёта';
-- Таблица сопоставления домов и лицевых счетов (для фильтра ОСВ 76 по дому и отображения в карточке дома)
CREATE TABLE IF NOT EXISTS building_personal_account_mappings (
id BIGSERIAL PRIMARY KEY,
building_id VARCHAR(50) NOT NULL REFERENCES buildings(id) ON DELETE CASCADE,
account_ls TEXT NOT NULL,
account_label TEXT,
apartment TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(building_id, account_ls)
);
CREATE INDEX IF NOT EXISTS idx_building_personal_account_mappings_building ON building_personal_account_mappings(building_id);
CREATE INDEX IF NOT EXISTS idx_building_personal_account_mappings_account_ls ON building_personal_account_mappings(account_ls);
COMMENT ON TABLE building_personal_account_mappings IS 'Сопоставление домов и лицевых счетов (для ОСВ 76): фильтр по дому, отображение в карточке дома';

View File

@@ -0,0 +1,24 @@
-- Миграция для создания таблицы ролей пользователей
CREATE TABLE IF NOT EXISTS user_roles (
id BIGSERIAL PRIMARY KEY,
user_id TEXT NOT NULL,
role VARCHAR(50) NOT NULL CHECK (role IN (
'manager',
'finance_manager',
'financier',
'finance_director',
'director',
'top_management'
)),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id, role)
);
-- Индексы
CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles(user_id);
CREATE INDEX IF NOT EXISTS idx_user_roles_role ON user_roles(role);
-- Комментарии
COMMENT ON TABLE user_roles IS 'Роли пользователей для workflow согласования счетов';
COMMENT ON COLUMN user_roles.role IS 'Роль: manager, finance_manager, financier, finance_director, director, top_management';

View File

@@ -0,0 +1,27 @@
-- Аудиты: статусы, индекс сложности, данные осмотра по пунктам/подпунктам
-- Статус аудита: новый, в работе, завершён
ALTER TABLE development_audits ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'new';
UPDATE development_audits SET status = 'completed' WHERE status IS NULL OR status = '';
ALTER TABLE development_audits ALTER COLUMN status SET DEFAULT 'new';
ALTER TABLE development_audits ALTER COLUMN status SET NOT NULL;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'development_audits_status_check') THEN
ALTER TABLE development_audits ADD CONSTRAINT development_audits_status_check
CHECK (status IN ('new', 'in_progress', 'completed'));
END IF;
END $$;
-- Индекс сложности дома (0100)
ALTER TABLE development_audits ADD COLUMN IF NOT EXISTS complexity_index NUMERIC(5, 2);
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'development_audits_complexity_index_check') THEN
ALTER TABLE development_audits ADD CONSTRAINT development_audits_complexity_index_check
CHECK (complexity_index IS NULL OR (complexity_index >= 0 AND complexity_index <= 100));
END IF;
END $$;
-- Данные осмотра: пункты и подпункты (JSONB)
ALTER TABLE development_audits ADD COLUMN IF NOT EXISTS inspection_data JSONB DEFAULT '{}';
CREATE INDEX IF NOT EXISTS idx_dev_audits_status ON development_audits(status);

View File

@@ -0,0 +1,7 @@
-- ОСС: повестка (пункты) и голоса по каждому пункту в реестре
-- Повестка собрания: массив формулировок пунктов (JSON array of strings)
ALTER TABLE development_oss_sessions ADD COLUMN IF NOT EXISTS agenda_items JSONB NOT NULL DEFAULT '[]';
-- Голоса по пунктам в реестре: объект { "0": "for", "1": "against", "2": "abstain" } (индекс пункта -> for/against/abstain)
ALTER TABLE development_oss_registry ADD COLUMN IF NOT EXISTS votes_by_item JSONB NOT NULL DEFAULT '{}';

View File

@@ -0,0 +1,17 @@
-- Добавляем этап «Анализ» (analysis) в воронку развития (после Входящие)
ALTER TABLE development_pipeline DROP CONSTRAINT IF EXISTS development_pipeline_status_check;
ALTER TABLE development_pipeline ADD CONSTRAINT development_pipeline_status_check
CHECK (status IN (
'incoming',
'analysis',
'agenda_approval',
'in_person',
'absentee',
'protocol_formation',
'protocol_to_gzhi',
'gzhi_order',
'success',
'failure'
));

View File

@@ -0,0 +1,31 @@
-- Воронка развития: новые этапы (9 пунктов)
-- 1) Входящие 2) Согласование повестки 3) Очная часть 4) Заочная часть
-- 5) Формирование протокола 6) Отправка протокола в ГЖИ 7) Приказ ГЖИ 8) Успех 9) Провал
-- Удаляем старый CHECK по status (автоимя в PG обычно development_pipeline_status_check)
ALTER TABLE development_pipeline DROP CONSTRAINT IF EXISTS development_pipeline_status_check;
-- Миграция старых статусов в новые
UPDATE development_pipeline SET status = 'incoming' WHERE status = 'analysis';
UPDATE development_pipeline SET status = 'agenda_approval' WHERE status = 'negotiation';
UPDATE development_pipeline SET status = 'in_person' WHERE status = 'preparation';
UPDATE development_pipeline SET status = 'absentee' WHERE status = 'voting';
UPDATE development_pipeline SET status = 'success' WHERE status = 'transfer';
-- Добавляем новый CHECK (включая этап «Анализ»)
ALTER TABLE development_pipeline ADD CONSTRAINT development_pipeline_status_check
CHECK (status IN (
'incoming',
'analysis',
'agenda_approval',
'in_person',
'absentee',
'protocol_formation',
'protocol_to_gzhi',
'gzhi_order',
'success',
'failure'
));
-- Дефолт для новых записей (на случай если где-то вставляют без status)
-- ALTER TABLE development_pipeline ALTER COLUMN status SET DEFAULT 'incoming';

View File

@@ -0,0 +1,15 @@
-- Обновление UNIQUE constraint для building_financial_data
-- Разрешаем накопление данных по периодам из разных отчетов
-- Удаляем старый constraint
ALTER TABLE building_financial_data
DROP CONSTRAINT IF EXISTS building_financial_data_building_id_period_start_period_end_period_type_key;
-- Создаем новый constraint, который включает report_id
-- Это позволяет иметь несколько записей для одного периода, но из разных отчетов
ALTER TABLE building_financial_data
ADD CONSTRAINT building_financial_data_unique
UNIQUE (building_id, report_id, period_start, period_end, period_type);
COMMENT ON CONSTRAINT building_financial_data_unique ON building_financial_data IS
'Уникальность по дому, отчету и периоду. Позволяет накапливать данные по периодам из разных отчетов.';

201
backend/notificationService.js Executable file
View File

@@ -0,0 +1,201 @@
/**
* Сервис уведомлений: создание записей только для затронутых пользователей (user_id).
* Не рассылаем «всем по роли» — только конкретным userId из сущности.
*/
/**
* Создать одно уведомление для пользователя.
* @param {object} pool - pg Pool
* @param {object} opts - { userId, type, title, body?, entityType?, entityId?, payload? }
* @returns {Promise<{ id: number }>}
*/
async function createNotification(pool, opts) {
const { userId, type, title, body = null, entityType = null, entityId = null, payload = null } = opts;
if (!userId || !type || !title) {
throw new Error('notificationService.createNotification: userId, type, title required');
}
const r = await pool.query(
`INSERT INTO notifications (user_id, type, title, body, entity_type, entity_id, payload, read_at, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NULL, NOW())
RETURNING id`,
[userId, type, title, body, entityType, entityId != null ? String(entityId) : null, payload ? JSON.stringify(payload) : null]
);
return { id: r.rows[0].id };
}
/**
* Создать уведомления для нескольких пользователей (один и тот же текст).
* @param {object} pool - pg Pool
* @param {number[]} userIds - portal_users.id (без дубликатов)
* @param {object} opts - { type, title, body?, entityType?, entityId?, payload? }
* @returns {Promise<number>} количество созданных записей
*/
async function createNotificationForUserIds(pool, userIds, opts) {
if (!userIds || userIds.length === 0) return 0;
const unique = [...new Set(userIds)].filter(Boolean);
let count = 0;
for (const uid of unique) {
await createNotification(pool, { ...opts, userId: uid });
count++;
}
return count;
}
/**
* Резолв employee_id(ы) в portal_users.id.
* @param {object} pool - pg Pool
* @param {string[]} employeeIds - employees.id
* @returns {Promise<number[]>} portal_users.id
*/
async function resolveEmployeeIdsToUserIds(pool, employeeIds) {
if (!employeeIds || employeeIds.length === 0) return [];
const unique = [...new Set(employeeIds)].filter(Boolean);
const r = await pool.query(
`SELECT id FROM portal_users WHERE employee_id = ANY($1::text[])`,
[unique]
);
return r.rows.map(row => Number(row.id));
}
/**
* Резолв одного employee_id в portal_users.id (или null).
* @param {object} pool - pg Pool
* @param {string} employeeId - employees.id
* @returns {Promise<number|null>}
*/
async function resolveEmployeeIdToUserId(pool, employeeId) {
if (!employeeId) return null;
const r = await pool.query(
`SELECT id FROM portal_users WHERE employee_id = $1 LIMIT 1`,
[employeeId]
);
return r.rows.length ? Number(r.rows[0].id) : null;
}
/**
* Резолв имён сотрудников (performer_name, responsible_name, assigned_to) в portal_users.id.
* @param {object} pool - pg Pool
* @param {string[]} names - массив имён (например ['Иванов И.И.', 'Петров П.П.'])
* @returns {Promise<number[]>} portal_users.id (без дубликатов)
*/
async function resolveEmployeeNamesToUserIds(pool, names) {
if (!names || names.length === 0) return [];
const trimmed = [...new Set(names.map(n => (n && String(n).trim())).filter(Boolean))];
if (trimmed.length === 0) return [];
const r = await pool.query(
`SELECT DISTINCT pu.id FROM portal_users pu
JOIN employees e ON e.id = pu.employee_id
WHERE e.name = ANY($1::text[])`,
[trimmed]
);
return r.rows.map(row => Number(row.id));
}
/**
* Создать уведомления для сотрудников (по employee_id); только тем, у кого есть portal_users.
* @param {object} pool - pg Pool
* @param {string[]} employeeIds - employees.id
* @param {object} opts - { type, title, body?, entityType?, entityId?, payload? }
* @returns {Promise<number>}
*/
async function createNotificationForEmployeeIds(pool, employeeIds, opts) {
const userIds = await resolveEmployeeIdsToUserIds(pool, employeeIds);
return createNotificationForUserIds(pool, userIds, opts);
}
/**
* Получить portal_users.id сотрудников, ответственных за зону (section + sub_id).
* Используется для целевых уведомлений по зонам ответственности.
* @param {object} pool - pg Pool
* @param {string} section - раздел (hr, finance, requests, pr, legal, development, office, admin)
* @param {string} subId - подраздел (employees, hiring, invoices, ...)
* @returns {Promise<number[]>} portal_users.id
*/
async function getResponsibleUserIdsForZone(pool, section, subId) {
if (!section || !subId) return [];
const r = await pool.query(
`SELECT DISTINCT pu.id FROM portal_users pu
JOIN employee_responsibility er ON er.employee_id = pu.employee_id
WHERE er.section = $1 AND er.sub_id = $2`,
[section, subId]
);
return r.rows.map(row => Number(row.id));
}
/**
* Создать уведомления для ответственных за зону (section + sub_id).
* @param {object} pool - pg Pool
* @param {string} section - раздел
* @param {string} subId - подраздел
* @param {object} opts - { type, title, body?, entityType?, entityId?, payload? }
* @returns {Promise<number>} количество созданных уведомлений
*/
async function createNotificationForResponsibleZone(pool, section, subId, opts) {
const userIds = await getResponsibleUserIdsForZone(pool, section, subId);
return createNotificationForUserIds(pool, userIds, opts);
}
const SECTION_IDS = ['dashboard', 'objects', 'requests', 'pr', 'finance', 'legal', 'development', 'hr', 'office', 'admin'];
const ROLE_ACCESS = {
DIRECTOR: ['all'],
ENGINEER: ['dashboard', 'objects', 'requests', 'office', 'development'],
MASTER: ['objects', 'requests'],
LAWYER: ['dashboard', 'legal', 'objects', 'requests'],
FINANCIER: ['dashboard', 'finance', 'office', 'objects'],
HR_MANAGER: ['dashboard', 'hr', 'office'],
PR_MANAGER: ['dashboard', 'pr', 'requests']
};
function allowedSectionsFromPermissions(permissions) {
if (!permissions || !Array.isArray(permissions) || permissions.length === 0) return null;
if (permissions.includes('all')) return SECTION_IDS;
const set = new Set();
for (const p of permissions) {
if (SECTION_IDS.includes(p)) set.add(p);
else if (typeof p === 'string' && p.includes('_')) {
const section = p.split('_')[0];
if (SECTION_IDS.includes(section)) set.add(section);
}
}
return Array.from(set);
}
/**
* Получить portal_users.id пользователей, у которых есть доступ к любому из указанных разделов (по permissions или роли).
* @param {object} pool - pg Pool
* @param {string[]} sections - разделы (dashboard, pr, finance, legal, development, hr, office, ...)
* @returns {Promise<number[]>} portal_users.id
*/
async function getPortalUserIdsBySections(pool, sections) {
if (!sections || sections.length === 0) return [];
const r = await pool.query(
`SELECT id, role, permissions FROM portal_users`
);
const wanted = new Set(sections);
const userIds = [];
for (const row of r.rows) {
let allowed;
if (row.permissions && Array.isArray(row.permissions) && row.permissions.length > 0) {
allowed = allowedSectionsFromPermissions(row.permissions);
} else {
const roleSections = ROLE_ACCESS[row.role];
allowed = roleSections && roleSections.includes('all') ? SECTION_IDS : (roleSections || []);
}
if (allowed && allowed.some(s => wanted.has(s))) {
userIds.push(Number(row.id));
}
}
return userIds;
}
module.exports = {
createNotification,
createNotificationForUserIds,
createNotificationForEmployeeIds,
createNotificationForResponsibleZone,
resolveEmployeeIdsToUserIds,
resolveEmployeeIdToUserId,
resolveEmployeeNamesToUserIds,
getResponsibleUserIdsForZone,
getPortalUserIdsBySections,
};

3494
backend/package-lock.json generated Executable file

File diff suppressed because it is too large Load Diff

25
backend/package.json Executable file
View File

@@ -0,0 +1,25 @@
{
"name": "mkd-control-center-backend",
"version": "1.0.0",
"main": "server.js",
"license": "MIT",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"axios": "^1.13.3",
"bcryptjs": "^2.4.3",
"cheerio": "^1.2.0",
"cors": "^2.8.5",
"csv-parser": "^3.0.0",
"dotenv": "^16.4.5",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"pg": "^8.13.1",
"puppeteer": "^24.36.1",
"react-quill": "^2.0.0",
"uuid": "^9.0.1",
"xlsx": "^0.18.5"
}
}

153
backend/paymentInvoiceWorkflow.js Executable file
View File

@@ -0,0 +1,153 @@
/**
* Модуль для управления workflow согласования счетов на оплату
*/
const WORKFLOW_STAGES = {
draft: {
next: 'pending_manager_approval',
role: null,
description: 'Черновик'
},
pending_manager_approval: {
next: 'pending_finance_manager_approval',
role: 'manager',
description: 'На согласовании у руководителя'
},
pending_finance_manager_approval: {
next: 'approved',
role: 'finance_manager',
description: 'На согласовании у финансового руководителя'
},
approved: {
next: 'scheduled',
role: 'financier',
description: 'Согласован, ожидает постановки в график'
},
scheduled: {
next: 'paid',
role: null,
description: 'В графике платежей'
},
paid: {
next: null,
role: null,
description: 'Оплачен'
},
postponed: {
next: 'scheduled',
role: null,
description: 'Отложен'
},
cancelled: {
next: null,
role: null,
description: 'Отменен'
},
rejected: {
next: 'draft',
role: null,
description: 'Отклонен'
},
completed: {
next: null,
role: null,
description: 'Выполнено'
}
};
// Роли высшего звена, которые пропускают этап manager
const TOP_MANAGEMENT_ROLES = ['finance_director', 'director', 'top_management'];
/**
* Определяет следующий статус для счета
* @param {string} currentStatus - текущий статус
* @param {string[]} userRoles - роли пользователя
* @returns {string|null} следующий статус
*/
function getNextStatus(currentStatus, userRoles = []) {
const stage = WORKFLOW_STAGES[currentStatus];
if (!stage) return null;
// Если текущий статус pending_manager_approval и пользователь из высшего звена
// пропускаем этап manager и идем сразу на finance_manager
if (currentStatus === 'pending_manager_approval' &&
userRoles.some(role => TOP_MANAGEMENT_ROLES.includes(role))) {
return 'pending_finance_manager_approval';
}
return stage.next;
}
/**
* Определяет, может ли пользователь согласовать счет на текущем этапе
* @param {string} currentStatus - текущий статус счета
* @param {string[]} userRoles - роли пользователя
* @returns {boolean}
*/
function canApprove(currentStatus, userRoles = []) {
const stage = WORKFLOW_STAGES[currentStatus];
if (!stage || !stage.role) return false;
// Если требуется роль manager, но пользователь из высшего звена - может согласовать
if (stage.role === 'manager' &&
userRoles.some(role => TOP_MANAGEMENT_ROLES.includes(role))) {
return true;
}
return userRoles.includes(stage.role);
}
/**
* Определяет роль следующего согласующего
* @param {string} currentStatus - текущий статус
* @param {string[]} userRoles - роли пользователя (для определения пропуска этапа)
* @returns {string|null}
*/
function getNextApproverRole(currentStatus, userRoles = []) {
const nextStatus = getNextStatus(currentStatus, userRoles);
if (!nextStatus) return null;
const nextStage = WORKFLOW_STAGES[nextStatus];
return nextStage ? nextStage.role : null;
}
/**
* Создает запись в истории согласования
* @param {string} userId - ID пользователя
* @param {string} userRole - роль пользователя
* @param {string} action - действие (approve, reject, etc.)
* @param {string} comment - комментарий
* @returns {object}
*/
function createApprovalHistoryEntry(userId, userRole, action, comment = '') {
return {
userId,
userRole,
action,
comment,
date: new Date().toISOString()
};
}
/**
* Генерирует номер счета
* @param {Date} date - дата создания
* @returns {string}
*/
function generateInvoiceNumber(date = new Date()) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const timestamp = Date.now().toString().slice(-6);
return `INV-${year}${month}${day}-${timestamp}`;
}
module.exports = {
WORKFLOW_STAGES,
TOP_MANAGEMENT_ROLES,
getNextStatus,
canApprove,
getNextApproverRole,
createApprovalHistoryEntry,
generateInvoiceNumber
};

561
backend/pipelineAutomation.js Executable file
View File

@@ -0,0 +1,561 @@
/**
* Модуль автоматизации воронки развития
* Обрабатывает автоматические переходы между стадиями, расчет probability,
* создание связанных записей (ОСС, аудиты, маркетинг)
*/
class PipelineAutomation {
constructor(pool) {
this.pool = pool;
}
/**
* Пересчет probability для объекта воронки
*/
async recalculateProbability(pipelineId) {
try {
// Получаем данные объекта
const pipelineResult = await this.pool.query(
'SELECT * FROM development_pipeline WHERE id = $1',
[pipelineId]
);
if (pipelineResult.rows.length === 0) {
throw new Error('Pipeline object not found');
}
const pipeline = pipelineResult.rows[0];
// Получаем маркетинговые данные
const marketingResult = await this.pool.query(
'SELECT * FROM development_marketing_activities WHERE building_id = $1 OR address = $2 LIMIT 1',
[pipeline.building_id, pipeline.address]
);
const marketing = marketingResult.rows[0] || {
activists_count: 0,
meetings_held: 0,
ads_distributed: 0
};
// Получаем данные аудита
const auditResult = await this.pool.query(
'SELECT * FROM development_audits WHERE building_id = $1 OR address = $2 ORDER BY audit_date DESC LIMIT 1',
[pipeline.building_id, pipeline.address]
);
const audit = auditResult.rows[0];
const auditScore = audit
? Math.max(0, 100 - audit.wear_percent) * (audit.projected_margin / 100)
: 50; // Средний балл если аудита нет
// Рассчитываем дни без активности
const daysInactive = Math.floor(
(Date.now() - new Date(pipeline.updated_at).getTime()) / (1000 * 60 * 60 * 24)
);
// Базовая вероятность
let baseProbability = 20;
// Модификаторы
const activistsBonus = Math.min(marketing.activists_count, 10) * 5;
const meetingsBonus = Math.min(marketing.meetings_held, 10) * 3;
const adsBonus = Math.min(marketing.ads_distributed / 10, 20);
const auditBonus = auditScore * 0.2;
const inactivityPenalty = Math.min(daysInactive * 0.5, 30);
// Итоговая вероятность
let newProbability = Math.round(
baseProbability +
activistsBonus +
meetingsBonus +
adsBonus +
auditBonus -
inactivityPenalty
);
// Ограничиваем от 0 до 100
newProbability = Math.max(0, Math.min(100, newProbability));
// Обновляем в БД
await this.pool.query(
'UPDATE development_pipeline SET probability = $1, updated_at = NOW() WHERE id = $2',
[newProbability, pipelineId]
);
return newProbability;
} catch (error) {
console.error('Error recalculating probability:', error);
throw error;
}
}
/**
* Проверка условий для автоматического перехода на следующую стадию
*/
async checkAutoTransition(pipelineId) {
try {
const pipelineResult = await this.pool.query(
'SELECT * FROM development_pipeline WHERE id = $1',
[pipelineId]
);
if (pipelineResult.rows.length === 0) {
return null;
}
const pipeline = pipelineResult.rows[0];
const currentStatus = pipeline.status;
// Новая воронка (9 этапов): авто-переходы отключены, только ручное редактирование
const newFunnelStatuses = ['incoming', 'analysis', 'agenda_approval', 'in_person', 'absentee', 'protocol_formation', 'protocol_to_gzhi', 'gzhi_order', 'success', 'failure'];
if (newFunnelStatuses.includes(currentStatus)) {
return null;
}
let newStatus = null;
let reason = '';
// Получаем маркетинговые данные
const marketingResult = await this.pool.query(
'SELECT * FROM development_marketing_activities WHERE building_id = $1 OR address = $2 LIMIT 1',
[pipeline.building_id, pipeline.address]
);
const marketing = marketingResult.rows[0];
// Получаем данные ОСС
const ossResult = await this.pool.query(
'SELECT * FROM development_oss_sessions WHERE building_id = $1 OR address = $2 ORDER BY created_at DESC LIMIT 1',
[pipeline.building_id, pipeline.address]
);
const oss = ossResult.rows[0];
// Правила переходов
if (currentStatus === 'analysis') {
// Analysis → Negotiation
if (pipeline.probability >= 30) {
newStatus = 'negotiation';
reason = 'Автоматический переход: вероятность >= 30%';
} else if (marketing && marketing.activists_count >= 1 && marketing.meetings_held >= 1) {
newStatus = 'negotiation';
reason = 'Автоматический переход: найдены активисты и проведена встреча';
}
} else if (currentStatus === 'negotiation') {
// Negotiation → Preparation
if (pipeline.probability >= 60 &&
marketing &&
marketing.activists_count >= 5 &&
marketing.meetings_held >= 3) {
newStatus = 'preparation';
reason = 'Автоматический переход: вероятность >= 60%, достаточно активистов и встреч';
}
} else if (currentStatus === 'preparation') {
// Preparation → Voting (при создании ОСС)
if (oss && oss.status === 'active') {
newStatus = 'voting';
reason = 'Автоматический переход: создано активное ОСС';
} else if (pipeline.probability >= 70 && oss) {
newStatus = 'voting';
reason = 'Автоматический переход: вероятность >= 70% и ОСС запланировано';
}
} else if (currentStatus === 'voting') {
// Voting → Transfer (при успешном завершении ОСС)
if (oss && oss.status === 'completed') {
const quorumPercent = (oss.quorum_current / oss.quorum_total) * 100;
if (quorumPercent > 50) {
newStatus = 'transfer';
reason = 'Автоматический переход: ОСС завершено успешно (кворум > 50%)';
}
}
}
// Откат стадий
if (currentStatus === 'negotiation' && pipeline.probability < 20) {
// Проверяем дни без активности
const daysInactive = Math.floor(
(Date.now() - new Date(pipeline.updated_at).getTime()) / (1000 * 60 * 60 * 24)
);
if (daysInactive > 30) {
newStatus = 'analysis';
reason = 'Автоматический откат: низкая вероятность и нет активности > 30 дней';
}
}
if (newStatus && newStatus !== currentStatus) {
await this.transitionStatus(pipelineId, currentStatus, newStatus, reason, 'auto');
return { from: currentStatus, to: newStatus, reason };
}
return null;
} catch (error) {
console.error('Error checking auto transition:', error);
throw error;
}
}
/**
* Переход объекта на новую стадию
*/
async transitionStatus(pipelineId, fromStatus, toStatus, reason, triggeredBy = 'manual') {
try {
// Получаем данные объекта для создания связанных записей
const pipelineResult = await this.pool.query(
'SELECT * FROM development_pipeline WHERE id = $1',
[pipelineId]
);
if (pipelineResult.rows.length === 0) {
throw new Error('Pipeline object not found');
}
const pipeline = pipelineResult.rows[0];
// Обновляем статус
await this.pool.query(
'UPDATE development_pipeline SET status = $1, updated_at = NOW() WHERE id = $2',
[toStatus, pipelineId]
);
// Логируем переход
await this.pool.query(
`INSERT INTO development_pipeline_history
(pipeline_id, from_status, to_status, reason, triggered_by)
VALUES ($1, $2, $3, $4, $5)`,
[pipelineId, fromStatus, toStatus, reason, triggeredBy]
);
// Автоматическое создание технического аудита при переходе на negotiation
if (toStatus === 'negotiation' && fromStatus !== 'negotiation') {
await this.createAutomaticAudit(pipeline);
}
// Если переход в 'transfer', создаем building если его нет
if (toStatus === 'transfer') {
await this.createBuildingFromPipeline(pipelineId);
}
return true;
} catch (error) {
console.error('Error transitioning status:', error);
throw error;
}
}
/**
* Автоматическое создание технического аудита для объекта
*/
async createAutomaticAudit(pipeline) {
try {
// Проверяем, есть ли уже аудит для этого объекта
const existingAudit = await this.pool.query(
'SELECT id FROM development_audits WHERE (building_id = $1 OR address = $2) LIMIT 1',
[pipeline.building_id, pipeline.address]
);
if (existingAudit.rows.length > 0) {
console.log(`[PipelineAutomation] Audit already exists for ${pipeline.address}`);
return;
}
// Создаем аудит с базовыми значениями
// Эти значения будут обновлены позже при реальном проведении аудита
const auditId = `a-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const auditDate = new Date().toISOString().split('T')[0];
// Базовые значения (будут обновлены при реальном аудите)
// Используем данные из pipeline для предварительной оценки
const estimatedWear = 30; // Средний износ для новых объектов
const estimatedMargin = 15; // Средняя маржа
const estimatedTariff = 35; // Средний тариф
await this.pool.query(
`INSERT INTO development_audits
(id, building_id, address, wear_percent, roof_condition, basement_condition,
calculated_tariff, projected_margin, audit_date, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[
auditId,
pipeline.building_id || null,
pipeline.address,
estimatedWear,
'good', // Предполагаем хорошее состояние
'good',
estimatedTariff,
estimatedMargin,
auditDate,
'Автоматически создан при переходе на стадию "Переговоры". Требуется обновление данных после реального аудита.'
]
);
console.log(`[PipelineAutomation] Created automatic audit ${auditId} for ${pipeline.address}`);
// Пересчитываем probability с учетом нового аудита
await this.recalculateProbability(pipeline.id);
} catch (error) {
console.error('[PipelineAutomation] Error creating automatic audit:', error);
// Не прерываем выполнение, если не удалось создать аудит
}
}
/**
* Создание building из pipeline объекта при переходе в transfer
*/
async createBuildingFromPipeline(pipelineId) {
try {
const pipelineResult = await this.pool.query(
'SELECT * FROM development_pipeline WHERE id = $1',
[pipelineId]
);
if (pipelineResult.rows.length === 0) {
throw new Error('Pipeline object not found');
}
const pipeline = pipelineResult.rows[0];
// Проверяем, есть ли уже building
if (pipeline.building_id) {
// Обновляем building_id в locations для карты
await this.pool.query(
`UPDATE development_building_locations
SET status = 'ours', building_id = $1
WHERE address = $2 OR building_id IS NULL AND address = $2`,
[pipeline.building_id, pipeline.address]
);
return pipeline.building_id;
}
// Создаем новый building
const buildingId = `b-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const newBuilding = {
id: buildingId,
districtId: null, // Можно определить по адресу
passport: {
address: pipeline.address,
apartmentsCount: pipeline.apartments,
general: {
address: pipeline.address,
floors: pipeline.floors,
totalArea: pipeline.area,
livingArea: pipeline.area * 0.8, // Примерная оценка
}
},
accounts: [],
financials: { balance: 0, debt: 0, collectionRate: 0 },
requests: { new: 0, inProgress: 0, overdue: 0 },
isDirty: true
};
await this.pool.query(
'INSERT INTO buildings (id, data) VALUES ($1, $2)',
[buildingId, JSON.stringify(newBuilding)]
);
// Автоматически создаем опрос NPS для нового дома
try {
const existingSurvey = await this.pool.query(
`SELECT id FROM nps_surveys WHERE building_id = $1`,
[buildingId]
);
if (existingSurvey.rows.length === 0) {
const accessKey = `nps-${buildingId}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
await this.pool.query(
`INSERT INTO nps_surveys
(building_id, title, description, status, access_key, created_by)
VALUES ($1, $2, $3, $4, $5, 'system')`,
[
buildingId,
'Опрос удовлетворенности жителей',
'Помогите нам стать лучше! Поделитесь своим мнением о качестве обслуживания.',
'draft',
accessKey
]
);
}
} catch (npsErr) {
console.error(`[pipelineAutomation] Ошибка создания опроса NPS для дома ${buildingId}:`, npsErr);
}
// Автоматически создаем отчет для нового дома (текущий месяц) с заполненными данными
try {
const now = new Date();
const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const currentMonthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59);
const months = [
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
];
const monthName = months[now.getMonth()];
const reportMonth = `${monthName} ${now.getFullYear()}`;
const existingReport = await this.pool.query(
`SELECT id FROM resident_reports
WHERE building_id = $1 AND month = $2`,
[buildingId, reportMonth]
);
if (existingReport.rows.length === 0) {
// Создаем отчет с начальными данными
const initialContent = {
applications: {
total: 156,
completed: 153,
quality: 98
},
finances: {
collected: 2400000,
expenses: 1890000,
balance: 560000
},
nps: {
score: 72,
totalResponses: 45
}
};
await this.pool.query(
`INSERT INTO resident_reports
(building_id, month, period_start, period_end, status, content, published_at)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, NOW())`,
[
buildingId,
reportMonth,
currentMonthStart.toISOString().split('T')[0],
currentMonthEnd.toISOString().split('T')[0],
'published',
JSON.stringify(initialContent)
]
);
}
} catch (reportErr) {
console.error(`[pipelineAutomation] Ошибка создания отчета для дома ${buildingId}:`, reportErr);
}
// Обновляем pipeline с building_id
await this.pool.query(
'UPDATE development_pipeline SET building_id = $1 WHERE id = $2',
[buildingId, pipelineId]
);
// Обновляем location на карте
await this.pool.query(
`INSERT INTO development_building_locations (building_id, address, status)
VALUES ($1, $2, 'ours')
ON CONFLICT (building_id) DO UPDATE SET status = 'ours'`,
[buildingId, pipeline.address]
);
return buildingId;
} catch (error) {
console.error('Error creating building from pipeline:', error);
throw error;
}
}
/**
* Обработка завершения ОСС
*/
async handleOSSCompletion(ossId) {
try {
const ossResult = await this.pool.query(
'SELECT * FROM development_oss_sessions WHERE id = $1',
[ossId]
);
if (ossResult.rows.length === 0) {
throw new Error('OSS session not found');
}
const oss = ossResult.rows[0];
// Находим связанный pipeline объект
const pipelineResult = await this.pool.query(
'SELECT * FROM development_pipeline WHERE building_id = $1 OR address = $2 LIMIT 1',
[oss.building_id, oss.address]
);
if (pipelineResult.rows.length === 0) {
return; // Нет связанного pipeline объекта
}
const pipeline = pipelineResult.rows[0];
if (oss.status === 'completed') {
const quorumPercent = (oss.quorum_current / oss.quorum_total) * 100;
if (quorumPercent > 50 && pipeline.status === 'voting') {
// Успешное ОСС - переводим в transfer
await this.transitionStatus(
pipeline.id,
'voting',
'transfer',
`ОСС завершено успешно (кворум ${quorumPercent.toFixed(1)}%)`,
'auto'
);
} else if (quorumPercent <= 50) {
// ОСС провалено - снижаем probability и откатываем
const newProbability = Math.max(0, pipeline.probability - 20);
await this.pool.query(
'UPDATE development_pipeline SET probability = $1 WHERE id = $2',
[newProbability, pipeline.id]
);
if (pipeline.status === 'voting') {
await this.transitionStatus(
pipeline.id,
'voting',
'preparation',
'ОСС провалено (кворум < 50%)',
'auto'
);
}
}
}
} catch (error) {
console.error('Error handling OSS completion:', error);
throw error;
}
}
/**
* Ежедневная задача для проверки всех объектов
*/
async dailyCheck() {
try {
console.log('[PipelineAutomation] Starting daily check...');
// Получаем все объекты воронки
const pipelineResult = await this.pool.query(
'SELECT id FROM development_pipeline WHERE status != $1',
['transfer']
);
let updated = 0;
let transitioned = 0;
for (const row of pipelineResult.rows) {
// Пересчитываем probability
await this.recalculateProbability(row.id);
updated++;
// Проверяем условия для перехода
const transition = await this.checkAutoTransition(row.id);
if (transition) {
transitioned++;
console.log(`[PipelineAutomation] Auto-transitioned ${row.id}: ${transition.from}${transition.to}`);
}
}
console.log(`[PipelineAutomation] Daily check completed: ${updated} updated, ${transitioned} transitioned`);
return { updated, transitioned };
} catch (error) {
console.error('[PipelineAutomation] Error in daily check:', error);
throw error;
}
}
}
module.exports = PipelineAutomation;

30
backend/read_act_xlsx.js Executable file
View File

@@ -0,0 +1,30 @@
const XLSX = require('xlsx');
const fs = require('fs');
const path = process.argv[2] || require('path').join(__dirname, 'Act_osmotr_103.xlsx');
const outPath = require('path').join(__dirname, 'act_output.txt');
let out = '';
function log(...args) { const s = args.join(' '); out += s + '\n'; console.log(...args); }
if (!fs.existsSync(path)) {
log('File not found:', path);
log('Copy Act_osmotr_103 (1).xlsx to backend folder and run: node read_act_xlsx.js');
process.exit(1);
}
const wb = XLSX.readFile(path);
log('=== SHEETS ===');
wb.SheetNames.forEach((name, i) => log(i, name));
wb.SheetNames.forEach((name) => {
log('\n=== SHEET:', name, '===');
const ws = wb.Sheets[name];
const ref = ws['!ref'] || 'A1';
const range = XLSX.utils.decode_range(ref);
log('Range:', ref, 'rows', range.e.r - range.s.r + 1, 'cols', range.e.c - range.s.c + 1);
const data = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' });
data.slice(0, 150).forEach((row, i) => log(JSON.stringify(row)));
});
fs.writeFileSync(outPath, out, 'utf8');
console.log('Written to', outPath);

139
backend/reviewApiService.js Executable file
View File

@@ -0,0 +1,139 @@
/**
* Сервис загрузки отзывов через API (2ГИС, Яндекс).
* Без парсинга страниц — только HTTP-запросы к API.
*/
let axios;
try {
axios = require('axios');
} catch (err) {
console.warn('[reviewApiService] axios не установлен. Установите: npm install axios');
}
const API_2GIS_BASE = 'https://catalog.api.2gis.com/3.0/items';
/**
* Извлечь firm_id из url_template или settings.
* @param {string} urlTemplate - URL вида https://2gis.ru/ufa/firm/2393065583658695/tab/reviews
* @param {object} settings - объект settings из parsing_settings (может содержать firm_id)
* @returns {string|null}
*/
function extractFirmId(urlTemplate, settings) {
if (settings && settings.firm_id) {
return String(settings.firm_id).trim();
}
if (urlTemplate && typeof urlTemplate === 'string') {
const match = urlTemplate.match(/\/firm\/(\d+)(?:\/|$)/);
if (match) return match[1];
}
return null;
}
/**
* Загрузка отзывов из API 2ГИС.
* По документации доступна сводка (reviews_summary: рейтинг 05, количество отзывов).
* Полный список текстов отзывов — по отдельному согласованию с 2ГИС.
* @param {object} settings - запись из parsing_settings (url_template, api_key, settings)
* @returns {Promise<Array<{ author_name, text, rating, date, source_url, building_id }>>}
*/
async function fetchFrom2GIS(settings) {
const reviews = [];
if (!axios) {
console.warn('[reviewApiService] axios недоступен');
return reviews;
}
const firmId = extractFirmId(settings.url_template, settings.settings);
if (!firmId) {
console.warn('[reviewApiService] 2ГИС: не удалось извлечь firm_id из URL или settings');
return reviews;
}
const apiKey = (settings.api_key || '').trim();
if (!apiKey) {
console.warn('[reviewApiService] 2ГИС: api_key не задан');
return reviews;
}
const urlTemplate = (settings.url_template || '').trim() || `https://2gis.ru/firm/${firmId}`;
const buildingId = (settings.settings && settings.settings.building_id) || null;
try {
const url = `${API_2GIS_BASE}?id=${encodeURIComponent(firmId)}&key=${encodeURIComponent(apiKey)}&fields=items.reviews_summary,items.address,items.name&locale=ru_RU`;
const response = await axios.get(url, { timeout: 15000 });
if (response.status !== 200) {
console.warn('[reviewApiService] 2ГИС: ответ', response.status);
return reviews;
}
const data = response.data;
const items = (data && data.result && data.result.items) || [];
if (items.length === 0) {
console.warn('[reviewApiService] 2ГИС: объект не найден или пустой items');
return reviews;
}
const item = items[0];
const summary = item.reviews_summary;
const name = item.name || 'Организация';
const addressName = (item.address && item.address.name) || '';
if (summary != null) {
const rating5 = typeof summary.rating === 'number' ? summary.rating : (summary.general_rating ?? 0);
const count = typeof summary.general_review_count === 'number' ? summary.general_review_count : (summary.review_count ?? 0);
const rating10 = Math.max(1, Math.min(10, Math.round(rating5 * 2)));
const today = new Date().toISOString().split('T')[0];
reviews.push({
author_name: '2ГИС',
text: `Сводка 2ГИС: рейтинг ${rating5.toFixed(1)}, отзывов ${count}. ${name}${addressName ? `, ${addressName}` : ''}`.substring(0, 5000),
rating: rating10,
date: today,
source_url: urlTemplate,
building_id: buildingId
});
}
console.log(`[reviewApiService] 2ГИС: загружено записей: ${reviews.length}`);
} catch (err) {
if (err.response) {
console.warn('[reviewApiService] 2ГИС API:', err.response.status, err.response.data && (err.response.data.message || err.response.data.error_message || ''));
} else {
console.warn('[reviewApiService] 2ГИС:', err.message);
}
}
return reviews;
}
/**
* Загрузка отзывов из API Яндекса.
* Публичного API отзывов организаций нет — возвращаем пустой массив.
*/
async function fetchFromYandex() {
console.log('[reviewApiService] Яндекс: отзывы через API недоступны. Используйте 2ГИС или виджет.');
return [];
}
/**
* Загрузить отзывы из API для указанного источника.
* @param {string} source - '2gis' | 'yandex_maps'
* @param {object} settings - запись из parsing_settings (url_template, api_key, settings)
* @returns {Promise<Array<{ author_name, text, rating, date, source_url, building_id }>>}
*/
async function fetchReviewsFromApi(source, settings) {
if (source === '2gis') {
return fetchFrom2GIS(settings);
}
if (source === 'yandex_maps') {
return fetchFromYandex(settings);
}
console.warn('[reviewApiService] Неизвестный источник:', source);
return [];
}
module.exports = {
fetchReviewsFromApi,
extractFirmId
};

1088
backend/reviewParser.js Executable file

File diff suppressed because it is too large Load Diff

450
backend/routes/buildings.js Executable file
View File

@@ -0,0 +1,450 @@
'use strict';
const express = require('express');
const BUILDINGS_CACHE_TTL_MS = 30 * 1000; // 30 секунд
function createResponseCache() {
const store = new Map();
return {
get(key) {
const entry = store.get(key);
if (!entry || Date.now() > entry.expiresAt) {
if (entry) store.delete(key);
return null;
}
return entry.data;
},
set(key, data, ttlMs = BUILDINGS_CACHE_TTL_MS) {
store.set(key, { data, expiresAt: Date.now() + ttlMs });
},
invalidatePrefix(prefix) {
for (const key of store.keys()) {
if (key.startsWith(prefix)) store.delete(key);
}
}
};
}
/**
* Buildings API router.
* @param {{ query: (text: string, params?: any[]) => Promise<any[]> }} deps
* @returns {express.Router}
*/
function createBuildingsRouter(deps) {
const { query } = deps;
const router = express.Router();
const cache = createResponseCache();
function invalidateBuildingsCache() {
cache.invalidatePrefix('buildings:');
}
// GET /buildings -> список домов (пагинация, опционально ?light=1). При scope=own_district — дома назначенных участков (employee_districts + fallback assigned_district_id).
router.get('/buildings', async (req, res) => {
try {
const limit = Math.min(Math.max(1, parseInt(req.query.limit, 10) || 100), 500);
const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
const light = req.query.light === '1' || req.query.light === 'true';
let districtIds = null; // null = все участки, [] = нет доступа, [id, ...] = только эти участки
if (req.user && req.user.userId) {
const userScopeRows = await query(
`SELECT pu.scope, e.id AS employee_id, e.assigned_district_id
FROM portal_users pu
JOIN employees e ON e.id = pu.employee_id
WHERE pu.id = $1`,
[req.user.userId]
);
const row = userScopeRows[0];
if (row && row.scope === 'own_district') {
let ids = [];
try {
const edRows = await query(
'SELECT district_id FROM employee_districts WHERE employee_id = $1 ORDER BY district_id',
[row.employee_id]
);
ids = (edRows || []).map((r) => r.district_id).filter(Boolean);
} catch (_) {}
if (ids.length === 0 && row.assigned_district_id) {
ids = [row.assigned_district_id];
}
districtIds = ids;
}
}
const cacheKey = districtIds === null
? `buildings:list:${limit}:${offset}:${light}`
: `buildings:list:${limit}:${offset}:${light}:districts:${[...districtIds].sort().join(',')}`;
const cached = districtIds === null ? cache.get(cacheKey) : null;
if (cached !== null) {
return res.json(cached);
}
let rows;
if (districtIds !== null && districtIds.length === 0) {
rows = [];
} else if (districtIds !== null && districtIds.length > 0) {
rows = await query(
`SELECT id, data FROM buildings WHERE (data->>'districtId') = ANY($1::text[]) ORDER BY id LIMIT $2 OFFSET $3`,
[districtIds, limit, offset]
);
} else {
rows = await query(
'SELECT id, data FROM buildings ORDER BY id LIMIT $1 OFFSET $2',
[limit, offset]
);
}
let data;
if (light) {
data = rows.map((r) => {
const d = r.data || {};
return {
id: d.id || r.id,
districtId: d.districtId ?? null,
passport: d.passport ? { address: d.passport.address || '' } : { address: '' }
};
});
} else {
data = rows.map((r) => r.data);
const ids = rows.map((r) => (r.data && r.data.id) || r.id).filter(Boolean);
if (ids.length > 0) {
try {
const placeholders = ids.map((_, i) => `$${i + 1}`).join(', ');
const accountRows = await query(
`SELECT building_id, data FROM building_personal_accounts WHERE building_id IN (${placeholders})`,
ids
);
const accountsByBuilding = {};
for (const ar of accountRows) {
if (!accountsByBuilding[ar.building_id]) accountsByBuilding[ar.building_id] = [];
accountsByBuilding[ar.building_id].push(ar.data);
}
for (const b of data) {
const bid = b.id;
b.accounts = Array.isArray(accountsByBuilding[bid]) ? accountsByBuilding[bid] : (Array.isArray(b.accounts) ? b.accounts : []);
}
} catch (e) {
if (!e.message || !e.message.includes('building_personal_accounts')) {
console.warn('Error loading accounts for list:', e.message);
}
}
}
}
if (districtIds === null) cache.set(cacheKey, data);
res.json(data);
} catch (err) {
console.error('Error fetching buildings:', err);
res.status(500).json({ error: 'Failed to fetch buildings' });
}
});
// POST /buildings -> создание нового дома
router.post('/buildings', async (req, res) => {
const building = req.body;
if (!building || !building.id || !building.passport || !building.passport.address) {
return res
.status(400)
.json({ error: 'Ожидается объект Building с полями id и passport.address' });
}
try {
await query('INSERT INTO buildings (id, data) VALUES ($1, $2)', [
building.id,
building,
]);
try {
const existingSurvey = await query(
`SELECT id FROM nps_surveys WHERE building_id = $1`,
[building.id]
);
if (existingSurvey.length === 0) {
const accessKey = `nps-${building.id}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
await query(
`INSERT INTO nps_surveys
(building_id, title, description, status, access_key, created_by)
VALUES ($1, $2, $3, $4, $5, 'system')`,
[
building.id,
'Опрос удовлетворенности жителей',
'Помогите нам стать лучше! Поделитесь своим мнением о качестве обслуживания.',
'draft',
accessKey
]
);
console.log(`[Auto NPS] Создан опрос для нового дома ${building.id}`);
}
} catch (npsErr) {
console.error(`[Auto NPS] Ошибка создания опроса для дома ${building.id}:`, npsErr);
}
try {
const now = new Date();
const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const currentMonthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59);
const months = [
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
];
const monthName = months[now.getMonth()];
const reportMonth = `${monthName} ${now.getFullYear()}`;
const existingReport = await query(
`SELECT id FROM resident_reports
WHERE building_id = $1 AND month = $2`,
[building.id, reportMonth]
);
if (existingReport.length === 0) {
const initialContent = {
applications: { total: 156, completed: 153, quality: 98 },
finances: { collected: 2400000, expenses: 1890000, balance: 560000 },
nps: { score: 72, totalResponses: 45 }
};
const newReport = await query(
`INSERT INTO resident_reports
(building_id, month, period_start, period_end, status, content, published_at)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, NOW())
RETURNING id`,
[
building.id,
reportMonth,
currentMonthStart.toISOString().split('T')[0],
currentMonthEnd.toISOString().split('T')[0],
'published',
JSON.stringify(initialContent)
]
);
console.log(`[Auto Report] Создан опубликованный отчет для нового дома ${building.id} (ID: ${newReport[0].id})`);
}
} catch (reportErr) {
console.error(`[Auto Report] Ошибка создания отчета для дома ${building.id}:`, reportErr);
}
invalidateBuildingsCache();
res.status(201).json(building);
} catch (err) {
console.error('Error creating building:', err);
res.status(500).json({ error: 'Failed to create building' });
}
});
// GET /buildings/:id — здание + лицевые счета (из building_personal_accounts или из data)
router.get('/buildings/:id', async (req, res) => {
try {
const { id } = req.params;
const cacheKey = `buildings:id:${id}`;
const cached = cache.get(cacheKey);
if (cached !== null) {
return res.json(cached);
}
const rows = await query('SELECT data FROM buildings WHERE id = $1', [id]);
if (!rows.length) {
return res.status(404).json({ error: 'Дом не найден' });
}
const data = rows[0].data;
try {
const accountRows = await query(
'SELECT id, data FROM building_personal_accounts WHERE building_id = $1',
[id]
);
if (accountRows.length > 0) {
data.accounts = accountRows.map((r) => r.data);
} else if (!Array.isArray(data.accounts)) {
data.accounts = [];
}
} catch (e) {
if (!Array.isArray(data.accounts)) data.accounts = [];
}
cache.set(cacheKey, data);
res.json(data);
} catch (err) {
console.error('Error fetching building:', err);
res.status(500).json({ error: err.message || 'Failed to fetch building' });
}
});
// PUT /buildings/:id — если есть таблица building_personal_accounts: не пишем accounts в data; иначе пишем всё (fallback)
router.put('/buildings/:id', async (req, res) => {
const { id } = req.params;
const building = req.body;
if (!building || !building.passport || !building.passport.address) {
return res
.status(400)
.json({ error: 'Ожидается объект Building с полями passport.address' });
}
try {
let useAccountsTable = false;
try {
await query('SELECT 1 FROM building_personal_accounts LIMIT 1');
useAccountsTable = true;
} catch (_) {}
const toSave = { ...building };
if (useAccountsTable) delete toSave.accounts;
await query('UPDATE buildings SET data = $2 WHERE id = $1', [id, toSave]);
invalidateBuildingsCache();
const dataForResponse = { ...toSave };
if (useAccountsTable) {
try {
const accountRows = await query(
'SELECT id, data FROM building_personal_accounts WHERE building_id = $1',
[id]
);
dataForResponse.accounts = accountRows.length > 0 ? accountRows.map((r) => r.data) : [];
} catch (e) {
dataForResponse.accounts = Array.isArray(building.accounts) ? building.accounts : [];
}
} else {
dataForResponse.accounts = Array.isArray(building.accounts) ? building.accounts : [];
}
res.json(dataForResponse);
} catch (err) {
console.error('Error updating building:', err);
res.status(500).json({ error: 'Failed to update building' });
}
});
// DELETE /buildings/:id
router.delete('/buildings/:id', async (req, res) => {
const { id } = req.params;
try {
const buildingRows = await query('SELECT id FROM buildings WHERE id = $1', [id]);
if (buildingRows.length === 0) {
return res.status(404).json({ error: 'Дом не найден' });
}
await query('DELETE FROM buildings WHERE id = $1', [id]);
invalidateBuildingsCache();
res.json({ success: true, message: 'Дом успешно удален' });
} catch (err) {
console.error('Error deleting building:', err);
res.status(500).json({ error: 'Ошибка при удалении дома' });
}
});
// POST /buildings/:id/accounts — в building_personal_accounts или в buildings.data (fallback)
router.post('/buildings/:id/accounts', async (req, res) => {
const { id: buildingId } = req.params;
const newAccount = req.body;
if (!newAccount || !newAccount.apartmentNumber) {
return res.status(400).json({ error: 'Ожидается объект PersonalAccount с полем apartmentNumber' });
}
try {
const buildingRows = await query('SELECT data FROM buildings WHERE id = $1', [buildingId]);
if (buildingRows.length === 0) {
return res.status(404).json({ error: 'Building not found' });
}
let useAccountsTable = false;
try {
await query('SELECT 1 FROM building_personal_accounts LIMIT 1');
useAccountsTable = true;
} catch (_) {}
const accounts = Array.isArray(buildingRows[0].data?.accounts) ? buildingRows[0].data.accounts : [];
if (!newAccount.id) newAccount.id = `${buildingId}-acc-${Date.now()}`;
if (!newAccount.accountNumber) newAccount.accountNumber = `${buildingId.replace('b-', '')}00${accounts.length + 1}`;
if (useAccountsTable) {
await query(
'INSERT INTO building_personal_accounts (id, building_id, data) VALUES ($1, $2, $3)',
[newAccount.id, buildingId, newAccount]
);
} else {
const building = buildingRows[0].data;
building.accounts = [...accounts, newAccount];
building.isDirty = true;
await query('UPDATE buildings SET data = $2 WHERE id = $1', [buildingId, building]);
}
invalidateBuildingsCache();
res.status(201).json(newAccount);
} catch (err) {
console.error('Error creating account:', err);
res.status(500).json({ error: 'Failed to create account' });
}
});
// PUT /buildings/:id/accounts/:accountId — обновление в building_personal_accounts или в data (fallback)
router.put('/buildings/:id/accounts/:accountId', async (req, res) => {
const { id: buildingId, accountId } = req.params;
const updatedAccount = req.body;
if (!updatedAccount || !updatedAccount.id) {
return res.status(400).json({ error: 'Ожидается объект PersonalAccount с полем id' });
}
try {
const buildingRows = await query('SELECT data FROM buildings WHERE id = $1', [buildingId]);
if (buildingRows.length === 0) {
return res.status(404).json({ error: 'Building not found' });
}
let useAccountsTable = false;
try {
await query('SELECT 1 FROM building_personal_accounts LIMIT 1');
useAccountsTable = true;
} catch (_) {}
if (useAccountsTable) {
const updateResult = await query(
'UPDATE building_personal_accounts SET data = $3 WHERE id = $1 AND building_id = $2 RETURNING id',
[accountId, buildingId, updatedAccount]
);
if (updateResult.length === 0) {
return res.status(404).json({ error: 'Account not found' });
}
} else {
const building = buildingRows[0].data;
const accounts = Array.isArray(building.accounts) ? building.accounts : [];
const idx = accounts.findIndex((a) => a.id === accountId);
if (idx === -1) return res.status(404).json({ error: 'Account not found' });
building.accounts = accounts.map((a) => (a.id === accountId ? updatedAccount : a));
building.isDirty = true;
await query('UPDATE buildings SET data = $2 WHERE id = $1', [buildingId, building]);
}
invalidateBuildingsCache();
res.json(updatedAccount);
} catch (err) {
console.error('Error updating account:', err);
res.status(500).json({ error: 'Failed to update account' });
}
});
// DELETE /buildings/:id/accounts/:accountId — из building_personal_accounts или из data (fallback)
router.delete('/buildings/:id/accounts/:accountId', async (req, res) => {
const { id: buildingId, accountId } = req.params;
try {
const buildingRows = await query('SELECT data FROM buildings WHERE id = $1', [buildingId]);
if (buildingRows.length === 0) {
return res.status(404).json({ error: 'Building not found' });
}
let useAccountsTable = false;
try {
await query('SELECT 1 FROM building_personal_accounts LIMIT 1');
useAccountsTable = true;
} catch (_) {}
if (useAccountsTable) {
const deleteResult = await query(
'DELETE FROM building_personal_accounts WHERE id = $1 AND building_id = $2 RETURNING id',
[accountId, buildingId]
);
if (deleteResult.length === 0) {
return res.status(404).json({ error: 'Account not found' });
}
} else {
const building = buildingRows[0].data;
const accounts = Array.isArray(building.accounts) ? building.accounts : [];
const filtered = accounts.filter((a) => a.id !== accountId);
if (filtered.length === accounts.length) {
return res.status(404).json({ error: 'Account not found' });
}
building.accounts = filtered;
building.isDirty = true;
await query('UPDATE buildings SET data = $2 WHERE id = $1', [buildingId, building]);
}
invalidateBuildingsCache();
res.json({ success: true, message: 'Account deleted' });
} catch (err) {
console.error('Error deleting account:', err);
res.status(500).json({ error: 'Failed to delete account' });
}
});
return router;
}
module.exports = createBuildingsRouter;

410
backend/salaryProcessor.js Executable file
View File

@@ -0,0 +1,410 @@
const { Pool } = require('pg');
/**
* Обработчик зарплатных данных из 1С
* Расширяет функциональность fileProcessor для работы с зарплатными отчетами
*/
class SalaryProcessor {
constructor(pool) {
this.pool = pool;
}
/**
* Обработка зарплатных данных из файла
* @param {Array} rows - Строки из файла после парсинга
* @param {Object} mapping - Маппинг полей из файла в структуру данных
* @param {number} reportId - ID отчета в БД
* @returns {Promise<Object>} Результат обработки
*/
async processSalaryData(rows, mapping, reportId) {
const client = await this.pool.connect();
let processedRows = 0;
let errorRows = 0;
let employeesUpdated = 0;
const errors = [];
try {
await client.query('BEGIN');
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const rowNum = i + 2; // +2 потому что первая строка - заголовок, нумерация с 1
try {
// Применяем маппинг полей
const mappedData = this.applyMapping(row, mapping);
// Валидация обязательных полей
const validation = this.validateSalaryRow(mappedData);
if (!validation.valid) {
errors.push({
row: rowNum,
message: validation.error,
data: mappedData
});
errorRows++;
continue;
}
// Находим сотрудника по идентификатору
const employee = await this.findEmployee(client, mappedData);
if (!employee) {
errors.push({
row: rowNum,
message: `Сотрудник не найден: ${mappedData.employeeIdentifier || 'не указан'}`,
suggestion: 'Проверьте ФИО, ИНН или табельный номер в файле'
});
errorRows++;
continue;
}
// Парсим период
const period = this.parsePeriod(mappedData.period);
if (!period) {
errors.push({
row: rowNum,
message: 'Не удалось определить период из данных',
data: mappedData.period
});
errorRows++;
continue;
}
// Сохраняем историю зарплаты
await this.saveSalaryHistory(client, {
employeeId: employee.id,
reportId: reportId,
periodMonth: period.month,
periodYear: period.year,
baseSalary: this.parseNumber(mappedData.baseSalary) || 0,
actualSalary: this.parseNumber(mappedData.actualSalary) || 0,
bonuses: this.parseNumber(mappedData.bonuses) || 0,
deductions: this.parseNumber(mappedData.deductions) || 0,
netSalary: this.parseNumber(mappedData.netSalary) || 0,
workedDays: this.parseNumber(mappedData.workedDays),
workedHours: this.parseNumber(mappedData.workedHours),
vacationDays: this.parseNumber(mappedData.vacationDays) || 0,
sickLeaveDays: this.parseNumber(mappedData.sickLeaveDays) || 0,
metadata: this.extractMetadata(mappedData)
});
// Обновляем текущую зарплату сотрудника (берем последний период)
if (mappedData.actualSalary || mappedData.baseSalary) {
const newSalary = this.parseNumber(mappedData.actualSalary) ||
this.parseNumber(mappedData.baseSalary) ||
employee.salary;
if (newSalary !== employee.salary) {
await client.query(
'UPDATE employees SET salary = $1, updated_at = NOW() WHERE id = $2',
[newSalary, employee.id]
);
employeesUpdated++;
}
}
processedRows++;
} catch (error) {
console.error(`Ошибка обработки строки ${rowNum}:`, error);
errors.push({
row: rowNum,
message: error.message,
data: row
});
errorRows++;
}
}
await client.query('COMMIT');
return {
success: true,
processedRows,
errorRows,
employeesUpdated,
errors: errors.slice(0, 50) // Ограничиваем количество ошибок в ответе
};
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
/**
* Применение маппинга полей из файла
*/
applyMapping(row, mapping) {
const mapped = {};
const columnMappings = mapping.columnMappings || {};
// Стандартные поля
const fieldMappings = {
employeeIdentifier: ['ФИО', 'Имя', 'Сотрудник', 'СотрудникФИО', 'employeeName', 'name'],
inn: ['ИНН', 'inn'],
snils: ['СНИЛС', 'snils'],
period: ['Период', 'Месяц', 'Дата', 'period', 'month', 'date'],
baseSalary: ['Оклад', 'Базовая зарплата', 'baseSalary', 'salary'],
actualSalary: ['Зарплата', 'Начислено', 'actualSalary', 'accrued'],
bonuses: ['Премия', 'Премии', 'bonuses', 'bonus'],
deductions: ['Удержано', 'Удержания', 'deductions', 'deduction'],
netSalary: ['К выплате', 'К выплате', 'netSalary', 'toPay'],
workedDays: ['Отработано дней', 'Дней', 'workedDays', 'days'],
workedHours: ['Отработано часов', 'Часов', 'workedHours', 'hours'],
vacationDays: ['Отпуск', 'Дни отпуска', 'vacationDays', 'vacation'],
sickLeaveDays: ['Больничный', 'Дни больничного', 'sickLeaveDays', 'sickLeave']
};
// Применяем маппинг
for (const [targetField, sourceFields] of Object.entries(fieldMappings)) {
for (const sourceField of sourceFields) {
// Сначала проверяем маппинг из настроек
const mappedField = columnMappings[sourceField];
if (mappedField && row[mappedField] !== undefined) {
mapped[targetField] = row[mappedField];
break;
}
// Затем проверяем прямое совпадение
if (row[sourceField] !== undefined) {
mapped[targetField] = row[sourceField];
break;
}
}
}
return mapped;
}
/**
* Валидация строки зарплатных данных
*/
validateSalaryRow(data) {
if (!data.employeeIdentifier && !data.inn) {
return {
valid: false,
error: 'Не указан идентификатор сотрудника (ФИО или ИНН)'
};
}
if (!data.period) {
return {
valid: false,
error: 'Не указан период (месяц/год)'
};
}
return { valid: true };
}
/**
* Поиск сотрудника по идентификатору
*/
async findEmployee(client, data) {
// Сначала пробуем найти по ИНН (самый точный способ)
if (data.inn) {
const result = await client.query(
`SELECT e.* FROM employees e
INNER JOIN employee_accounting_data a ON e.id = a.employee_id
WHERE a.inn = $1`,
[data.inn]
);
if (result.rows.length > 0) {
return result.rows[0];
}
}
// Затем по СНИЛС
if (data.snils) {
const result = await client.query(
`SELECT e.* FROM employees e
INNER JOIN employee_accounting_data a ON e.id = a.employee_id
WHERE a.snils = $1`,
[data.snils]
);
if (result.rows.length > 0) {
return result.rows[0];
}
}
// Затем по ФИО (частичное совпадение)
if (data.employeeIdentifier) {
const nameParts = data.employeeIdentifier.trim().split(/\s+/);
if (nameParts.length >= 2) {
// Ищем по фамилии и имени
const lastName = nameParts[0];
const firstName = nameParts[1];
const result = await client.query(
`SELECT * FROM employees
WHERE name ILIKE $1 OR name ILIKE $2`,
[`%${lastName}%${firstName}%`, `%${firstName}%${lastName}%`]
);
if (result.rows.length === 1) {
return result.rows[0];
} else if (result.rows.length > 1) {
// Несколько совпадений - нужен более точный поиск
// Пробуем точное совпадение
const exactMatch = result.rows.find(e =>
e.name.toLowerCase() === data.employeeIdentifier.toLowerCase()
);
if (exactMatch) {
return exactMatch;
}
}
}
}
return null;
}
/**
* Парсинг периода из строки
* Поддерживает форматы: "01.2024", "январь 2024", "2024-01", "01/2024"
*/
parsePeriod(periodStr) {
if (!periodStr) return null;
const str = String(periodStr).trim();
// Формат "01.2024" или "01/2024"
const dotMatch = str.match(/^(\d{1,2})[./](\d{4})$/);
if (dotMatch) {
const month = parseInt(dotMatch[1], 10);
const year = parseInt(dotMatch[2], 10);
if (month >= 1 && month <= 12 && year >= 2000) {
return { month, year };
}
}
// Формат "2024-01"
const dashMatch = str.match(/^(\d{4})-(\d{1,2})$/);
if (dashMatch) {
const year = parseInt(dashMatch[1], 10);
const month = parseInt(dashMatch[2], 10);
if (month >= 1 && month <= 12 && year >= 2000) {
return { month, year };
}
}
// Формат "январь 2024" или "Январь 2024"
const monthNames = {
'январь': 1, 'февраль': 2, 'март': 3, 'апрель': 4,
'май': 5, 'июнь': 6, 'июль': 7, 'август': 8,
'сентябрь': 9, 'октябрь': 10, 'ноябрь': 11, 'декабрь': 12
};
for (const [monthName, monthNum] of Object.entries(monthNames)) {
const regex = new RegExp(`${monthName}\\s+(\\d{4})`, 'i');
const match = str.match(regex);
if (match) {
const year = parseInt(match[1], 10);
if (year >= 2000) {
return { month: monthNum, year };
}
}
}
// Пробуем распарсить как дату
const date = new Date(str);
if (!isNaN(date.getTime())) {
return {
month: date.getMonth() + 1,
year: date.getFullYear()
};
}
return null;
}
/**
* Парсинг числа из строки
*/
parseNumber(value) {
if (value === null || value === undefined || value === '') {
return null;
}
if (typeof value === 'number') {
return value;
}
// Убираем пробелы и заменяем запятую на точку
const cleaned = String(value)
.replace(/\s/g, '')
.replace(',', '.');
const parsed = parseFloat(cleaned);
return isNaN(parsed) ? null : parsed;
}
/**
* Извлечение дополнительных метаданных
*/
extractMetadata(data) {
const metadata = {};
const knownFields = [
'employeeIdentifier', 'inn', 'snils', 'period',
'baseSalary', 'actualSalary', 'bonuses', 'deductions', 'netSalary',
'workedDays', 'workedHours', 'vacationDays', 'sickLeaveDays'
];
for (const [key, value] of Object.entries(data)) {
if (!knownFields.includes(key) && value !== null && value !== undefined) {
metadata[key] = value;
}
}
return Object.keys(metadata).length > 0 ? metadata : null;
}
/**
* Сохранение истории зарплаты
*/
async saveSalaryHistory(client, data) {
await client.query(
`INSERT INTO employee_salary_history (
employee_id, report_id, period_month, period_year,
base_salary, actual_salary, bonuses, deductions, net_salary,
worked_days, worked_hours, vacation_days, sick_leave_days,
metadata, imported_from_1c
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
ON CONFLICT (employee_id, period_month, period_year)
DO UPDATE SET
base_salary = EXCLUDED.base_salary,
actual_salary = EXCLUDED.actual_salary,
bonuses = EXCLUDED.bonuses,
deductions = EXCLUDED.deductions,
net_salary = EXCLUDED.net_salary,
worked_days = EXCLUDED.worked_days,
worked_hours = EXCLUDED.worked_hours,
vacation_days = EXCLUDED.vacation_days,
sick_leave_days = EXCLUDED.sick_leave_days,
metadata = EXCLUDED.metadata,
report_id = EXCLUDED.report_id,
updated_at = NOW()`,
[
data.employeeId,
data.reportId,
data.periodMonth,
data.periodYear,
data.baseSalary,
data.actualSalary,
data.bonuses,
data.deductions,
data.netSalary,
data.workedDays,
data.workedHours,
data.vacationDays,
data.sickLeaveDays,
data.metadata ? JSON.stringify(data.metadata) : null,
true
]
);
}
}
module.exports = SalaryProcessor;

919
backend/schema.sql Executable file
View File

@@ -0,0 +1,919 @@
-- Создание базы данных (выполните один раз под суперпользователем postgres):
-- CREATE DATABASE mkd_control_center WITH ENCODING 'UTF8';
-- Далее подключитесь к базе mkd_control_center и выполните это тело файла.
-- ========= УЧАСТКИ =========
CREATE TABLE IF NOT EXISTS districts (
id VARCHAR(50) PRIMARY KEY,
name TEXT NOT NULL,
manager_name TEXT NOT NULL
);
-- ========= ДОМА =========
-- Для упрощения: вся структура Building хранится в одном JSONB-поле data.
CREATE TABLE IF NOT EXISTS buildings (
id VARCHAR(50) PRIMARY KEY,
data JSONB NOT NULL
);
-- ========= ЗАЯВКИ =========
CREATE TYPE doma_application_status AS ENUM ('new', 'in_progress', 'deferred', 'done', 'canceled');
CREATE TABLE IF NOT EXISTS applications (
id BIGSERIAL PRIMARY KEY,
number TEXT NOT NULL,
status doma_application_status NOT NULL,
description TEXT NOT NULL,
address TEXT NOT NULL,
apartment TEXT NOT NULL,
client_name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deadline_at TIMESTAMPTZ NOT NULL,
performer_name TEXT,
employee_id VARCHAR(50) REFERENCES employees(id) ON DELETE SET NULL,
building_id VARCHAR(50) REFERENCES buildings(id) ON DELETE SET NULL,
is_overdue BOOLEAN NOT NULL DEFAULT FALSE,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_applications_building ON applications(building_id);
CREATE INDEX IF NOT EXISTS idx_applications_employee ON applications(employee_id);
CREATE INDEX IF NOT EXISTS idx_applications_performer ON applications(performer_name);
CREATE INDEX IF NOT EXISTS idx_applications_status ON applications(status);
CREATE INDEX IF NOT EXISTS idx_applications_overdue ON applications(is_overdue);
CREATE INDEX IF NOT EXISTS idx_applications_deadline ON applications(deadline_at);
-- ========= ТЕСТОВЫЕ ДАННЫЕ =========
-- Демо данные удалены. Создавайте участки и дома через интерфейс приложения.
-- INSERT INTO districts (id, name, manager_name) VALUES
-- ('d-1', 'Участок №1 (Центральный)', 'Смирнов А.В.'),
-- ('d-2', 'Участок №2 (Заречный)', 'Петров Б.Г.')
-- ON CONFLICT (id) DO NOTHING;
-- INSERT INTO buildings (id, data) VALUES ...
-- ON CONFLICT (id) DO NOTHING;
-- INSERT INTO applications (number, status, description, address, apartment, client_name, created_at, deadline_at, performer_name)
-- VALUES ...
-- ON CONFLICT DO NOTHING;
-- ========= ФИНАНСОВЫЕ ДАННЫЕ ИЗ 1С =========
-- Настройки маппинга колонок CSV/XLSX (создаем первой, т.к. на неё ссылается financial_reports)
CREATE TABLE IF NOT EXISTS financial_report_mappings (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
is_default BOOLEAN DEFAULT FALSE,
column_mappings JSONB NOT NULL, -- { "source_column": "target_field", ... }
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Метаданные загруженных отчетов
CREATE TABLE IF NOT EXISTS financial_reports (
id BIGSERIAL PRIMARY KEY,
filename TEXT NOT NULL,
file_type VARCHAR(10) NOT NULL CHECK (file_type IN ('CSV', 'XLSX')),
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
uploaded_by TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'processing' CHECK (status IN ('processing', 'completed', 'failed', 'partial')),
total_rows INTEGER,
processed_rows INTEGER DEFAULT 0,
error_rows INTEGER DEFAULT 0,
error_log JSONB,
mapping_id BIGINT REFERENCES financial_report_mappings(id) ON DELETE SET NULL
);
-- Финансовые данные по домам
CREATE TABLE IF NOT EXISTS building_financial_data (
id BIGSERIAL PRIMARY KEY,
building_id VARCHAR(50) NOT NULL REFERENCES buildings(id) ON DELETE CASCADE,
report_id BIGINT REFERENCES financial_reports(id) ON DELETE SET NULL,
period_start DATE NOT NULL,
period_end DATE NOT NULL,
period_type VARCHAR(10) NOT NULL CHECK (period_type IN ('month', 'quarter', 'year')),
-- Доходы
total_income NUMERIC(15, 2) DEFAULT 0,
income_by_items JSONB, -- { "item_name": amount, ... }
-- Расходы
total_expenses NUMERIC(15, 2) DEFAULT 0,
expenses_by_items JSONB, -- { "item_name": amount, ... }
-- Баланс
balance NUMERIC(15, 2) DEFAULT 0,
-- Дополнительные данные
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(building_id, period_start, period_end, period_type)
);
CREATE INDEX IF NOT EXISTS idx_building_financial_data_building ON building_financial_data(building_id);
CREATE INDEX IF NOT EXISTS idx_building_financial_data_period ON building_financial_data(period_start, period_end);
CREATE INDEX IF NOT EXISTS idx_building_financial_data_report ON building_financial_data(report_id);
-- ========= ОФИС: ЗАЯВКИ НА РЕМОНТ ТЕХНИКИ =========
CREATE TYPE office_equipment_type AS ENUM ('pc', 'laptop', 'air_conditioner', 'printer', 'other');
CREATE TYPE repair_request_status AS ENUM (
'new',
'search_contractor', -- Поиск подрядчика
'agreed_with_contractor', -- Договорились с подрядчиком
'waiting_delivery', -- Ожидание поставки
'taken_for_repair', -- Увезли на ремонт
'self_repair', -- Ремонт самостоятельно
'in_progress', -- В работе
'completed', -- Выполнена
'canceled' -- Отменена
);
-- Справочник оборудования
CREATE TABLE IF NOT EXISTS office_equipment (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
type office_equipment_type NOT NULL,
brand TEXT,
model TEXT,
serial_number TEXT UNIQUE,
assigned_to TEXT, -- Имя сотрудника
purchase_date DATE,
warranty_until DATE,
next_maintenance_date DATE, -- Дата следующего ТО
condition VARCHAR(20) NOT NULL DEFAULT 'good' CHECK (condition IN ('good', 'fair', 'poor')),
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Заявки на ремонт
CREATE TABLE IF NOT EXISTS office_repair_requests (
id BIGSERIAL PRIMARY KEY,
equipment_id BIGINT NOT NULL REFERENCES office_equipment(id) ON DELETE CASCADE,
requester_name TEXT NOT NULL,
description TEXT NOT NULL,
priority VARCHAR(10) NOT NULL DEFAULT 'medium' CHECK (priority IN ('low', 'medium', 'high', 'urgent')),
status repair_request_status NOT NULL DEFAULT 'new',
assigned_to TEXT, -- Исполнитель
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
canceled_at TIMESTAMPTZ,
cancel_reason TEXT,
solution TEXT, -- Решение/что было сделано
expected_return_date DATE, -- Ожидаемая дата возврата из ремонта
attachments TEXT[], -- Пути к файлам (фото проблемы и т.д.)
comments JSONB, -- [{ "author": "name", "text": "comment", "created_at": "timestamp" }]
is_paid BOOLEAN DEFAULT FALSE, -- Платный ремонт
cost NUMERIC(10, 2) DEFAULT 0, -- Стоимость ремонта
cost_estimated BOOLEAN DEFAULT FALSE, -- Стоимость предварительная (требуется диагностика)
invoice_id BIGINT, -- ID счета на оплату из финансов (если создан)
invoice_url TEXT, -- Ссылка на счет
waiting_delivery_deadline TEXT, -- Примерный срок при ожидание поставки
waiting_delivery_contacts TEXT, -- Контакты для уточнения поставки
taken_for_repair_deadline TEXT, -- Срок при увезли на ремонт
taken_for_repair_contacts TEXT, -- Контакты при увезли на ремонт
agreed_contractor_price NUMERIC(10, 2) -- Цена при договорились с подрядчиком
);
CREATE INDEX IF NOT EXISTS idx_repair_requests_equipment ON office_repair_requests(equipment_id);
CREATE INDEX IF NOT EXISTS idx_repair_requests_status ON office_repair_requests(status);
CREATE INDEX IF NOT EXISTS idx_repair_requests_created ON office_repair_requests(created_at DESC);
-- История перемещений и событий по оборудованию
CREATE TYPE office_equipment_history_type AS ENUM ('purchase', 'issue', 'transfer', 'repair', 'write_off');
CREATE TABLE IF NOT EXISTS office_equipment_history (
id BIGSERIAL PRIMARY KEY,
equipment_id BIGINT NOT NULL REFERENCES office_equipment(id) ON DELETE CASCADE,
event_type office_equipment_history_type NOT NULL,
event_date DATE NOT NULL,
assigned_to TEXT, -- кому выдано / кому передано (для issue, transfer)
assigned_from TEXT, -- от кого (для transfer)
reason TEXT, -- причина (для repair, write_off)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_equipment_history_equipment ON office_equipment_history(equipment_id);
CREATE INDEX IF NOT EXISTS idx_equipment_history_date ON office_equipment_history(event_date DESC);
-- ========= ОФИС: БАЗА ЗНАНИЙ =========
-- Категории базы знаний
CREATE TABLE IF NOT EXISTS knowledge_base_categories (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
parent_id BIGINT REFERENCES knowledge_base_categories(id) ON DELETE CASCADE,
description TEXT,
icon TEXT,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_kb_categories_parent ON knowledge_base_categories(parent_id);
-- Статьи базы знаний
CREATE TABLE IF NOT EXISTS knowledge_base_articles (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
category_id BIGINT REFERENCES knowledge_base_categories(id) ON DELETE SET NULL,
content TEXT NOT NULL, -- Markdown или HTML
content_type VARCHAR(10) NOT NULL DEFAULT 'markdown' CHECK (content_type IN ('markdown', 'html')),
author TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
parent_version_id BIGINT REFERENCES knowledge_base_articles(id) ON DELETE SET NULL, -- Для версионирования
is_published BOOLEAN DEFAULT TRUE,
tags TEXT[],
attachments TEXT[], -- Пути к прикрепленным файлам
view_count INTEGER DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
published_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_kb_articles_category ON knowledge_base_articles(category_id);
CREATE INDEX IF NOT EXISTS idx_kb_articles_slug ON knowledge_base_articles(slug);
CREATE INDEX IF NOT EXISTS idx_kb_articles_published ON knowledge_base_articles(is_published, published_at DESC);
-- ========= ОФИС: СОВЕЩАНИЯ И ПЕРЕГОВОРНЫЕ =========
-- Переговорные комнаты
CREATE TABLE IF NOT EXISTS meeting_rooms (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
capacity INTEGER NOT NULL,
location TEXT,
equipment TEXT[], -- ['projector', 'whiteboard', 'video_conference']
description TEXT,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Совещания
CREATE TABLE IF NOT EXISTS meetings (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
organizer TEXT NOT NULL,
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ NOT NULL,
room_id BIGINT REFERENCES meeting_rooms(id) ON DELETE SET NULL,
participants TEXT[] NOT NULL, -- Список участников
agenda TEXT, -- Повестка дня
notes TEXT, -- Заметки/протокол
conclusions TEXT, -- Заключения совещания
status VARCHAR(20) NOT NULL DEFAULT 'scheduled' CHECK (status IN ('scheduled', 'in_progress', 'completed', 'canceled')),
reminder_sent BOOLEAN DEFAULT FALSE,
reminder_time TIMESTAMPTZ, -- Когда отправлять напоминание
attachments TEXT[], -- Прикрепленные файлы
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CHECK (end_time > start_time)
);
CREATE INDEX IF NOT EXISTS idx_meetings_room ON meetings(room_id);
CREATE INDEX IF NOT EXISTS idx_meetings_time ON meetings(start_time, end_time);
CREATE INDEX IF NOT EXISTS idx_meetings_status ON meetings(status);
-- Бронирования переговорных (отдельная таблица для истории)
CREATE TABLE IF NOT EXISTS meeting_bookings (
id BIGSERIAL PRIMARY KEY,
room_id BIGINT NOT NULL REFERENCES meeting_rooms(id) ON DELETE CASCADE,
meeting_id BIGINT REFERENCES meetings(id) ON DELETE CASCADE,
booked_by TEXT NOT NULL,
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ NOT NULL,
purpose TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'completed', 'canceled')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CHECK (end_time > start_time)
-- Примечание: проверка на пересечение времени выполняется на уровне приложения
-- в API endpoint для предотвращения конфликтов бронирований
);
CREATE INDEX IF NOT EXISTS idx_meeting_bookings_room ON meeting_bookings(room_id);
CREATE INDEX IF NOT EXISTS idx_meeting_bookings_time ON meeting_bookings(start_time, end_time);
CREATE INDEX IF NOT EXISTS idx_meeting_bookings_status ON meeting_bookings(status);
-- ========= ОФИС: ЗАЯВКИ НА ТМЦ (КАНЦТОВАРЫ, ХОЗ. НУЖДЫ) =========
CREATE TABLE IF NOT EXISTS office_supply_requests (
id BIGSERIAL PRIMARY KEY,
requester_name TEXT NOT NULL, -- Сотрудник, создавший заявку
category VARCHAR(50) NOT NULL, -- 'stationery', 'household', 'food', 'other'
item_name TEXT NOT NULL,
quantity INTEGER DEFAULT 1,
issued_quantity INTEGER DEFAULT 0, -- Количество выданного сотруднику
unit VARCHAR(20) DEFAULT 'шт.',
amount NUMERIC(10, 2) DEFAULT 0,
priority VARCHAR(10) NOT NULL DEFAULT 'medium' CHECK (priority IN ('low', 'medium', 'high', 'urgent')),
status VARCHAR(20) NOT NULL DEFAULT 'new' CHECK (status IN ('new', 'approved', 'ordered', 'received', 'canceled', 'archived', 'collected')),
approved_by TEXT, -- Кто одобрил
approved_at TIMESTAMPTZ,
ordered_at TIMESTAMPTZ,
received_at TIMESTAMPTZ,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_supply_requests_status ON office_supply_requests(status);
CREATE INDEX IF NOT EXISTS idx_supply_requests_requester ON office_supply_requests(requester_name);
CREATE INDEX IF NOT EXISTS idx_supply_requests_created ON office_supply_requests(created_at DESC);
-- ========= ОФИС: ЗАКАЗЫ =========
-- Заказы создаются из заявок на ТМЦ, могут включать несколько заявок
-- Заказ может ждать счет, иметь несколько предложений от поставщиков
CREATE TABLE IF NOT EXISTS office_orders (
id BIGSERIAL PRIMARY KEY,
order_number TEXT UNIQUE NOT NULL, -- Номер заказа (ORD-YYYY-MMDD-XXXX)
title TEXT NOT NULL, -- Название заказа
description TEXT, -- Описание заказа
status VARCHAR(20) NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'waiting_quote', 'quotes_received', 'approved', 'ordered', 'received', 'canceled')),
total_amount NUMERIC(10, 2) DEFAULT 0, -- Общая сумма заказа
supplier_name TEXT, -- Название поставщика
supplier_contact TEXT, -- Контакты поставщика
invoice_id BIGINT, -- ID счета на оплату из финансов (если создан)
invoice_url TEXT, -- Ссылка на счет
expected_date DATE, -- Ожидаемая дата получения
received_date DATE, -- Дата получения
notes TEXT,
created_by TEXT NOT NULL, -- Кто создал заказ
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Связь заказов с заявками (многие ко многим)
CREATE TABLE IF NOT EXISTS office_order_items (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES office_orders(id) ON DELETE CASCADE,
request_id BIGINT NOT NULL REFERENCES office_supply_requests(id) ON DELETE CASCADE,
quantity INTEGER DEFAULT 1, -- Количество в заказе (может отличаться от заявки)
unit_price NUMERIC(10, 2) DEFAULT 0, -- Цена за единицу в заказе
total_price NUMERIC(10, 2) DEFAULT 0, -- Общая цена позиции
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(order_id, request_id)
);
-- Предложения от поставщиков для заказа
CREATE TABLE IF NOT EXISTS office_order_quotes (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES office_orders(id) ON DELETE CASCADE,
supplier_name TEXT NOT NULL,
supplier_contact TEXT,
total_amount NUMERIC(10, 2) NOT NULL,
quote_file_url TEXT, -- Файл с коммерческим предложением
notes TEXT,
is_selected BOOLEAN DEFAULT FALSE, -- Выбранное предложение
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_orders_status ON office_orders(status);
CREATE INDEX IF NOT EXISTS idx_orders_created ON office_orders(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_order_items_order ON office_order_items(order_id);
CREATE INDEX IF NOT EXISTS idx_order_items_request ON office_order_items(request_id);
CREATE INDEX IF NOT EXISTS idx_order_quotes_order ON office_order_quotes(order_id);
-- ========= ОФИС: СКЛАД =========
CREATE TABLE IF NOT EXISTS office_inventory (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
category VARCHAR(50), -- 'stationery', 'household', 'food', 'other'
quantity NUMERIC(10, 2) NOT NULL DEFAULT 0,
unit VARCHAR(20) NOT NULL DEFAULT 'шт.',
min_threshold NUMERIC(10, 2) NOT NULL DEFAULT 0,
last_restock DATE,
last_restock_by TEXT, -- Кто пополнил
location TEXT, -- Место хранения
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_office_inventory_category ON office_inventory(category);
CREATE INDEX IF NOT EXISTS idx_office_inventory_name ON office_inventory(name);
-- ========= ОФИС: ДОКУМЕНТООБОРОТ =========
CREATE TABLE IF NOT EXISTS office_documents (
id BIGSERIAL PRIMARY KEY,
reg_number TEXT NOT NULL UNIQUE, -- Регистрационный номер
title TEXT NOT NULL,
correspondent TEXT NOT NULL, -- Корреспондент
document_type VARCHAR(20) NOT NULL CHECK (document_type IN ('incoming', 'outgoing')),
status VARCHAR(20) NOT NULL DEFAULT 'registered' CHECK (status IN ('registered', 'processed', 'sent', 'archived')),
date DATE NOT NULL,
assigned_to TEXT, -- Кому назначен
tracking_number TEXT, -- Трек-номер для отправленных
file_url TEXT, -- Путь к файлу
notes TEXT,
created_by TEXT NOT NULL, -- Кто создал/зарегистрировал
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_office_documents_reg_number ON office_documents(reg_number);
CREATE INDEX IF NOT EXISTS idx_office_documents_status ON office_documents(status);
CREATE INDEX IF NOT EXISTS idx_office_documents_type ON office_documents(document_type);
CREATE INDEX IF NOT EXISTS idx_office_documents_date ON office_documents(date DESC);
CREATE INDEX IF NOT EXISTS idx_office_documents_created_by ON office_documents(created_by);
-- ========= HR: СОТРУДНИКИ =========
CREATE TYPE employee_status AS ENUM ('active', 'vacation', 'inactive');
CREATE TYPE messenger_type AS ENUM ('Max', 'Telegram');
-- Основная таблица сотрудников
CREATE TABLE IF NOT EXISTS employees (
id VARCHAR(50) PRIMARY KEY,
name TEXT NOT NULL, -- ФИО
position TEXT NOT NULL, -- Должность
phone TEXT NOT NULL, -- Номер телефона
status employee_status NOT NULL DEFAULT 'active',
salary NUMERIC(10, 2) NOT NULL,
assigned_district_id VARCHAR(50) REFERENCES districts(id) ON DELETE SET NULL,
manager_id VARCHAR(50) REFERENCES employees(id) ON DELETE SET NULL, -- Руководитель сотрудника
birth_date DATE,
photo_url TEXT,
registration_date DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_employees_district ON employees(assigned_district_id);
CREATE INDEX IF NOT EXISTS idx_employees_status ON employees(status);
CREATE INDEX IF NOT EXISTS idx_employees_manager ON employees(manager_id);
-- ========= СТАТИСТИКА ПРОИЗВОДИТЕЛЬНОСТИ СОТРУДНИКОВ =========
CREATE TABLE IF NOT EXISTS employee_performance_stats (
id BIGSERIAL PRIMARY KEY,
employee_name TEXT NOT NULL,
period_start DATE NOT NULL,
period_end DATE NOT NULL,
-- Статистика по заявкам
total_assigned INTEGER NOT NULL DEFAULT 0, -- Всего заявок назначено
total_completed INTEGER NOT NULL DEFAULT 0, -- Выполнено заявок
total_overdue INTEGER NOT NULL DEFAULT 0, -- Просрочено заявок
total_in_progress INTEGER NOT NULL DEFAULT 0, -- В работе
total_deferred INTEGER NOT NULL DEFAULT 0, -- Отложено
-- Расчётные показатели
completion_rate NUMERIC(5, 2) NOT NULL DEFAULT 0, -- Процент выполнения (с учётом общего контекста)
overdue_rate NUMERIC(5, 2) NOT NULL DEFAULT 0, -- Процент просрочек
performance_score NUMERIC(5, 2) NOT NULL DEFAULT 0, -- Общий рейтинг производительности (0-100)
-- Связи
district_id VARCHAR(50) REFERENCES districts(id) ON DELETE SET NULL,
-- Метаданные
calculated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(employee_name, period_start, period_end)
);
CREATE INDEX IF NOT EXISTS idx_performance_stats_employee ON employee_performance_stats(employee_name);
CREATE INDEX IF NOT EXISTS idx_performance_stats_district ON employee_performance_stats(district_id);
CREATE INDEX IF NOT EXISTS idx_performance_stats_period ON employee_performance_stats(period_start, period_end);
CREATE INDEX IF NOT EXISTS idx_performance_stats_score ON employee_performance_stats(performance_score DESC);
-- ========= СТАТИСТИКА ПО УЧАСТКАМ =========
CREATE TABLE IF NOT EXISTS district_performance_stats (
id BIGSERIAL PRIMARY KEY,
district_id VARCHAR(50) NOT NULL REFERENCES districts(id) ON DELETE CASCADE,
period_start DATE NOT NULL,
period_end DATE NOT NULL,
-- Статистика по заявкам
total_applications INTEGER NOT NULL DEFAULT 0,
total_completed INTEGER NOT NULL DEFAULT 0,
total_overdue INTEGER NOT NULL DEFAULT 0,
total_in_progress INTEGER NOT NULL DEFAULT 0,
-- Расчётные показатели
completion_rate NUMERIC(5, 2) NOT NULL DEFAULT 0,
overdue_rate NUMERIC(5, 2) NOT NULL DEFAULT 0,
average_score NUMERIC(5, 2) NOT NULL DEFAULT 0, -- Средний рейтинг сотрудников участка
-- Метаданные
calculated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(district_id, period_start, period_end)
);
CREATE INDEX IF NOT EXISTS idx_district_stats_district ON district_performance_stats(district_id);
CREATE INDEX IF NOT EXISTS idx_district_stats_period ON district_performance_stats(period_start, period_end);
-- Логины мессенджеров
CREATE TABLE IF NOT EXISTS employee_messenger_logins (
id BIGSERIAL PRIMARY KEY,
employee_id VARCHAR(50) NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
messenger messenger_type NOT NULL,
login TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(employee_id, messenger)
);
CREATE INDEX IF NOT EXISTS idx_messenger_logins_employee ON employee_messenger_logins(employee_id);
-- HR данные: Паспортные данные
CREATE TABLE IF NOT EXISTS employee_passport_data (
id BIGSERIAL PRIMARY KEY,
employee_id VARCHAR(50) NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
series TEXT NOT NULL,
number TEXT NOT NULL,
issued_by TEXT NOT NULL,
issued_date DATE NOT NULL,
registration_address TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(employee_id)
);
CREATE INDEX IF NOT EXISTS idx_passport_data_employee ON employee_passport_data(employee_id);
-- HR данные: Трудовая книжка
CREATE TABLE IF NOT EXISTS employee_labor_books (
id BIGSERIAL PRIMARY KEY,
employee_id VARCHAR(50) NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
number TEXT NOT NULL,
series TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(employee_id)
);
CREATE INDEX IF NOT EXISTS idx_labor_books_employee ON employee_labor_books(employee_id);
-- Записи в трудовой книжке
CREATE TABLE IF NOT EXISTS labor_book_entries (
id BIGSERIAL PRIMARY KEY,
labor_book_id BIGINT NOT NULL REFERENCES employee_labor_books(id) ON DELETE CASCADE,
date DATE NOT NULL,
organization TEXT NOT NULL,
position TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_labor_book_entries_labor_book ON labor_book_entries(labor_book_id);
-- Заказ справок
CREATE TYPE certificate_status AS ENUM ('requested', 'issued', 'ready');
CREATE TABLE IF NOT EXISTS employee_certificates (
id BIGSERIAL PRIMARY KEY,
employee_id VARCHAR(50) NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
type TEXT NOT NULL, -- Тип справки (2-НДФЛ, справка с места работы и т.д.)
requested_date DATE NOT NULL,
issued_date DATE,
status certificate_status NOT NULL DEFAULT 'requested',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_certificates_employee ON employee_certificates(employee_id);
CREATE INDEX IF NOT EXISTS idx_certificates_status ON employee_certificates(status);
-- Прочие документы
CREATE TABLE IF NOT EXISTS employee_other_documents (
id BIGSERIAL PRIMARY KEY,
employee_id VARCHAR(50) NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
name TEXT NOT NULL,
type TEXT NOT NULL,
date DATE NOT NULL,
file_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_other_documents_employee ON employee_other_documents(employee_id);
-- Типовые документы HR (печать/шаблоны в разделе Кадры)
CREATE TABLE IF NOT EXISTS hr_template_documents (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
file_path TEXT NOT NULL,
original_filename TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_hr_template_documents_created ON hr_template_documents(created_at DESC);
-- Бухгалтерская информация
CREATE TABLE IF NOT EXISTS employee_accounting_data (
id BIGSERIAL PRIMARY KEY,
employee_id VARCHAR(50) NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
inn TEXT, -- ИНН
snils TEXT, -- СНИЛС
bank_name TEXT, -- Название банка
bank_account TEXT, -- Расчетный счет
correspondent_account TEXT, -- Корреспондентский счет
bik TEXT, -- БИК
tax_id TEXT, -- КПП (если есть)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(employee_id)
);
CREATE INDEX IF NOT EXISTS idx_accounting_data_employee ON employee_accounting_data(employee_id);
-- Характеристики договора
CREATE TABLE IF NOT EXISTS employee_contracts (
id BIGSERIAL PRIMARY KEY,
employee_id VARCHAR(50) NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
contract_type TEXT NOT NULL, -- Тип договора (трудовой, ГПХ, срочный и т.д.)
contract_number TEXT, -- Номер договора
start_date DATE NOT NULL, -- Дата начала
end_date DATE, -- Дата окончания (NULL для бессрочного)
probation_period_days INTEGER, -- Испытательный срок в днях
work_schedule TEXT, -- График работы (полный день, частичная занятость и т.д.)
work_mode TEXT, -- Режим работы (офис, удаленно, гибрид)
contract_terms TEXT, -- Дополнительные условия договора
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_contracts_employee ON employee_contracts(employee_id);
CREATE INDEX IF NOT EXISTS idx_contracts_dates ON employee_contracts(start_date, end_date);
-- ========= HR: ОТПУСКА =========
CREATE TABLE IF NOT EXISTS employee_vacations (
id BIGSERIAL PRIMARY KEY,
employee_id VARCHAR(50) NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
days_count INTEGER NOT NULL, -- Количество дней отпуска
vacation_type TEXT NOT NULL DEFAULT 'annual', -- Тип отпуска (annual - ежегодный, unpaid - без сохранения зарплаты, study - учебный и т.д.)
status VARCHAR(20) NOT NULL DEFAULT 'planned' CHECK (status IN ('planned', 'approved', 'active', 'completed', 'canceled', 'rejected')),
approved_by TEXT, -- Кто утвердил
approved_at TIMESTAMPTZ, -- Дата утверждения
notes TEXT, -- Примечания
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CHECK (end_date >= start_date)
);
CREATE INDEX IF NOT EXISTS idx_vacations_employee ON employee_vacations(employee_id);
CREATE INDEX IF NOT EXISTS idx_vacations_dates ON employee_vacations(start_date, end_date);
CREATE INDEX IF NOT EXISTS idx_vacations_status ON employee_vacations(status);
-- ========= HR: БОЛЬНИЧНЫЕ =========
CREATE TABLE IF NOT EXISTS employee_sick_leaves (
id BIGSERIAL PRIMARY KEY,
employee_id VARCHAR(50) NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
start_date DATE NOT NULL,
end_date DATE, -- Может быть NULL, если больничный еще не закрыт
days_count INTEGER, -- Количество дней (рассчитывается автоматически)
sick_leave_number TEXT, -- Номер больничного листа
diagnosis TEXT, -- Диагноз (если указан)
medical_institution TEXT, -- Медицинское учреждение
status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'closed', 'canceled')),
closed_at TIMESTAMPTZ, -- Дата закрытия больничного
expected_return_date DATE, -- Предварительная дата выхода
notes TEXT, -- Примечания
file_url TEXT, -- Ссылка на отсканированный больничный лист
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_sick_leaves_employee ON employee_sick_leaves(employee_id);
CREATE INDEX IF NOT EXISTS idx_sick_leaves_dates ON employee_sick_leaves(start_date, end_date);
CREATE INDEX IF NOT EXISTS idx_sick_leaves_status ON employee_sick_leaves(status);
-- ========= HR: УВОЛЬНЕНИЯ =========
CREATE TYPE termination_status AS ENUM ('initiated', 'in_progress', 'completed', 'canceled');
CREATE TABLE IF NOT EXISTS employee_terminations (
id BIGSERIAL PRIMARY KEY,
employee_id VARCHAR(50) NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
termination_date DATE NOT NULL, -- Дата увольнения
reason TEXT NOT NULL, -- Причина увольнения
initiated_by TEXT NOT NULL, -- Кто инициировал увольнение
initiated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
status termination_status NOT NULL DEFAULT 'initiated',
-- Договор на увольнение
termination_contract_number TEXT, -- Номер договора на увольнение
termination_contract_date DATE, -- Дата договора
termination_contract_file_url TEXT, -- Файл договора
-- Расчеты
final_settlement_amount NUMERIC(10, 2), -- Сумма окончательного расчета
unused_vacation_days INTEGER, -- Неиспользованные дни отпуска
compensation_amount NUMERIC(10, 2), -- Компенсация за неиспользованный отпуск
severance_pay NUMERIC(10, 2), -- Выходное пособие
other_payments NUMERIC(10, 2), -- Прочие выплаты
deductions NUMERIC(10, 2), -- Удержания
settlement_document_number TEXT, -- Номер документа расчета
settlement_document_date DATE, -- Дата документа расчета
settlement_document_file_url TEXT, -- Файл документа расчета
-- Дополнительная информация
notes TEXT, -- Примечания
completed_at TIMESTAMPTZ, -- Дата завершения процедуры увольнения
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_terminations_employee ON employee_terminations(employee_id);
CREATE INDEX IF NOT EXISTS idx_terminations_date ON employee_terminations(termination_date);
CREATE INDEX IF NOT EXISTS idx_terminations_status ON employee_terminations(status);
-- ========= HR: ОТГУЛЫ И ПРОГУЛЫ =========
CREATE TABLE IF NOT EXISTS employee_absences (
id BIGSERIAL PRIMARY KEY,
employee_id VARCHAR(50) NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
absence_type VARCHAR(20) NOT NULL CHECK (absence_type IN ('day_off', 'absence', 'late', 'early_leave')), -- Тип: отгул, прогул, опоздание, ранний уход
start_date DATE NOT NULL,
end_date DATE, -- Может быть NULL для однодневных отгулов
start_time TIME, -- Время начала (для опозданий и ранних уходов)
end_time TIME, -- Время окончания (для опозданий и ранних уходов)
days_count DECIMAL(5, 2) NOT NULL DEFAULT 1.0, -- Количество дней (может быть дробным для части дня)
reason TEXT, -- Причина отсутствия
requires_approval BOOLEAN NOT NULL DEFAULT TRUE, -- Требуется ли согласование
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected', 'canceled')), -- Статус согласования
approved_by TEXT, -- Кто утвердил (руководитель)
approved_at TIMESTAMPTZ, -- Дата и время утверждения
approved_signature TEXT, -- Подпись руководителя (текст или путь к файлу)
rejected_by TEXT, -- Кто отклонил
rejected_at TIMESTAMPTZ, -- Дата отклонения
rejection_reason TEXT, -- Причина отклонения
notes TEXT, -- Примечания
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CHECK (end_date IS NULL OR end_date >= start_date)
);
CREATE INDEX IF NOT EXISTS idx_absences_employee ON employee_absences(employee_id);
CREATE INDEX IF NOT EXISTS idx_absences_dates ON employee_absences(start_date, end_date);
CREATE INDEX IF NOT EXISTS idx_absences_type ON employee_absences(absence_type);
CREATE INDEX IF NOT EXISTS idx_absences_status ON employee_absences(status);
-- Обновляем таблицу отпусков для поддержки согласования
ALTER TABLE employee_vacations
ADD COLUMN IF NOT EXISTS requires_approval BOOLEAN NOT NULL DEFAULT TRUE,
ADD COLUMN IF NOT EXISTS approved_signature TEXT,
ADD COLUMN IF NOT EXISTS rejected_by TEXT,
ADD COLUMN IF NOT EXISTS rejected_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS rejection_reason TEXT;
-- Обновляем таблицу больничных для поддержки согласования (если требуется)
ALTER TABLE employee_sick_leaves
ADD COLUMN IF NOT EXISTS requires_approval BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS approved_by TEXT,
ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS approved_signature TEXT;
-- ========= ПРОФИЛИ ПОЛЬЗОВАТЕЛЕЙ ПОРТАЛА =========
-- Профили пользователей портала
-- Важно: профиль не может быть без сотрудника (employee_id NOT NULL)
-- Но сотрудник может быть без профиля (связь необязательная со стороны employees)
CREATE TABLE IF NOT EXISTS portal_users (
id BIGSERIAL PRIMARY KEY,
employee_id VARCHAR(50) NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
email TEXT,
login TEXT UNIQUE,
photo_url TEXT, -- Путь к загруженному фото
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(employee_id) -- Один профиль на одного сотрудника
);
CREATE INDEX IF NOT EXISTS idx_portal_users_employee ON portal_users(employee_id);
CREATE INDEX IF NOT EXISTS idx_portal_users_login ON portal_users(login);
-- Изменяем таблицу employees: photo_url теперь хранит путь к загруженному файлу
-- (или может быть NULL, если фото берется из профиля пользователя)
-- ========= HR: ВАКАНСИИ =========
-- Создание типа vacancy_status (с проверкой существования)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'vacancy_status') THEN
CREATE TYPE vacancy_status AS ENUM ('urgent', 'active', 'paused', 'closed');
END IF;
END$$;
-- Таблица вакансий
CREATE TABLE IF NOT EXISTS vacancies (
id VARCHAR(50) PRIMARY KEY,
position TEXT NOT NULL, -- Название должности
department TEXT NOT NULL, -- Отдел
status vacancy_status NOT NULL DEFAULT 'active',
salary TEXT, -- Вилка зарплаты (например, "55 000 - 65 000 ₽")
description TEXT NOT NULL, -- Описание вакансии
requirements TEXT, -- Требования к кандидату
conditions TEXT, -- Условия работы
responsibilities TEXT, -- Обязанности
posted_date DATE NOT NULL DEFAULT CURRENT_DATE, -- Дата публикации
closing_date DATE, -- Дата закрытия вакансии
applicants_count INTEGER DEFAULT 0, -- Количество откликов
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_vacancies_status ON vacancies(status);
CREATE INDEX IF NOT EXISTS idx_vacancies_department ON vacancies(department);
CREATE INDEX IF NOT EXISTS idx_vacancies_posted_date ON vacancies(posted_date DESC);
-- ========= HR: КАНДИДАТЫ =========
-- Создание типа candidate_stage (с проверкой существования)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'candidate_stage') THEN
CREATE TYPE candidate_stage AS ENUM ('new', 'interview', 'probation', 'hired', 'rejected');
ELSE
-- Добавляем 'probation' если его еще нет
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = 'probation'
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'candidate_stage')
) THEN
ALTER TYPE candidate_stage ADD VALUE IF NOT EXISTS 'probation';
END IF;
-- Удаляем 'offer' если он есть (заменяем на 'probation')
-- Примечание: удаление значений из ENUM в PostgreSQL невозможно напрямую,
-- поэтому оставляем 'offer' для обратной совместимости, но не используем в новом коде
END IF;
END$$;
-- Таблица кандидатов
CREATE TABLE IF NOT EXISTS candidates (
id VARCHAR(50) PRIMARY KEY,
name TEXT NOT NULL, -- ФИО кандидата
position TEXT NOT NULL, -- Позиция, на которую претендует
vacancy_id VARCHAR(50) REFERENCES vacancies(id) ON DELETE SET NULL, -- Связь с вакансией
stage candidate_stage NOT NULL DEFAULT 'new',
phone TEXT NOT NULL,
email TEXT,
resume_url TEXT, -- Ссылка на резюме
cover_letter TEXT, -- Сопроводительное письмо
interview_date TIMESTAMPTZ, -- Дата собеседования
interview_notes TEXT, -- Заметки с собеседования
offer_salary NUMERIC(10, 2), -- Предложенная зарплата
offer_date DATE, -- Дата предложения
hired_date DATE, -- Дата найма
rejected_reason TEXT, -- Причина отказа
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_candidates_vacancy ON candidates(vacancy_id);
CREATE INDEX IF NOT EXISTS idx_candidates_stage ON candidates(stage);
CREATE INDEX IF NOT EXISTS idx_candidates_position ON candidates(position);
-- ========= HR: СОБЫТИЯ КАНДИДАТОВ =========
-- Создание типа candidate_event_type
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'candidate_event_type') THEN
CREATE TYPE candidate_event_type AS ENUM (
'call', -- Созвон
'interview_1', -- Первое собеседование
'interview_2', -- Второе собеседование
'interview_3', -- Третье собеседование
'test_task', -- Тестовое задание
'offer', -- Оффер
'offer_accepted', -- Оффер принят
'offer_rejected', -- Оффер отклонен
'probation_start', -- Начало испытательного срока
'hired', -- Трудоустроен
'rejected', -- Отклонен
'other' -- Другое
);
END IF;
END$$;
-- Создание типа candidate_event_result
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'candidate_event_result') THEN
CREATE TYPE candidate_event_result AS ENUM ('success', 'failed', 'pending', 'cancelled');
END IF;
END$$;
-- Таблица событий кандидата
CREATE TABLE IF NOT EXISTS candidate_events (
id VARCHAR(50) PRIMARY KEY,
candidate_id VARCHAR(50) NOT NULL REFERENCES candidates(id) ON DELETE CASCADE,
event_type candidate_event_type NOT NULL,
event_date TIMESTAMPTZ NOT NULL,
notes TEXT, -- Заметки о событии
result candidate_event_result DEFAULT 'pending', -- Результат события
interviewer TEXT, -- Кто проводил (для собеседований)
location TEXT, -- Место проведения (офис, онлайн и т.д.)
duration_minutes INTEGER, -- Длительность в минутах
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_candidate_events_candidate ON candidate_events(candidate_id);
CREATE INDEX IF NOT EXISTS idx_candidate_events_type ON candidate_events(event_type);
CREATE INDEX IF NOT EXISTS idx_candidate_events_date ON candidate_events(event_date DESC);

24107
backend/server.js Executable file

File diff suppressed because it is too large Load Diff

4
backend/templates/accounts.csv Executable file
View File

@@ -0,0 +1,4 @@
building_id,apartment_number
b-1,1
b-1,2
b-2,1
1 building_id apartment_number
2 b-1 1
3 b-1 2
4 b-2 1

View File

@@ -0,0 +1,3 @@
id,address,district_id
b-1,ул. Примерная, 1,d-1
b-2,ул. Примерная, 2,d-1
1 id,address,district_id
2 b-1,ул. Примерная, 1,d-1
3 b-2,ул. Примерная, 2,d-1

View File

@@ -0,0 +1,3 @@
id,name,manager_name
d-1,Участок 1,Иванов И.И.
d-2,Участок 2,Петров П.П.
1 id name manager_name
2 d-1 Участок 1 Иванов И.И.
3 d-2 Участок 2 Петров П.П.

View File

@@ -0,0 +1,3 @@
id,name,position,phone,status,salary,assigned_district_id
e-1,Иванов Иван Иванович,Мастер участка,+7 900 111-22-33,active,50000,d-1
e-2,Петров Пётр Петрович,Слесарь-сантехник,+7 900 222-33-44,active,45000,d-1
1 id name position phone status salary assigned_district_id
2 e-1 Иванов Иван Иванович Мастер участка +7 900 111-22-33 active 50000 d-1
3 e-2 Петров Пётр Петрович Слесарь-сантехник +7 900 222-33-44 active 45000 d-1

Some files were not shown because too many files have changed in this diff Show More