Initial commit MKD fixes
This commit is contained in:
4
backend/.env
Executable file
4
backend/.env
Executable 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
173
backend/1c-integration-options.md
Executable 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
135
backend/HOW_REPORTS_WORK.md
Executable file
@@ -0,0 +1,135 @@
|
||||
# Как работает обработка отчетов из 1С
|
||||
|
||||
## Оборотно-сальдовая ведомость по счету 20
|
||||
|
||||
### Распознавание файла
|
||||
|
||||
Файл `Оборотно_сальдовая_ведомость_по_счету_20_за_2025_г_ООО_Дружба.csv` **автоматически распознается** как `balance_sheet` по ключевым словам в названии:
|
||||
- "оборотн"
|
||||
- "сальд"
|
||||
- "ведомост"
|
||||
|
||||
### Формат CSV (схема колонок и чисел)
|
||||
|
||||
- **Разделитель**: точка с запятой (`;`).
|
||||
- **Кодировка**: UTF-8 (при наличии BOM он автоматически убирается).
|
||||
- **Строки 1–9**: метаданные и заголовки (организация, «Счет, Наименование счета», «Обороты за период», «Статьи затрат» и т.д.). Строка начала данных определяется автоматически (по подстроке «Обороты за период» / «Счет, Наименование» или по первой строке, где в колонке 0 есть текст, а в колонке 3 или 4 — число).
|
||||
|
||||
**Колонки (индексы 0–6):**
|
||||
|
||||
| Индекс | Содержимое |
|
||||
|--------|------------|
|
||||
| 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 убирается.
|
||||
- Строки 1–8: метаданные, заголовки «Счет», «Контрагенты», «Дебет», «Кредит». Начало данных определяется автоматически (по «Обороты за период» / «Контрагенты» или по первой строке с числом в колонке 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
239
backend/INTEGRATION_SALARY_1C.md
Executable 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
26
backend/README_MIGRATION.md
Executable 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
189
backend/aiChatService.js
Executable 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
312
backend/aiToolsRegistry.js
Executable 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
|
||||
};
|
||||
157
backend/balanceSheet76Processor.js
Executable file
157
backend/balanceSheet76Processor.js
Executable 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
487
backend/balanceSheetProcessor.js
Executable 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
54
backend/constants/roleAccess.js
Executable 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,
|
||||
};
|
||||
33
backend/create_vacancies_table.sql
Executable file
33
backend/create_vacancies_table.sql
Executable 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
3607
backend/dbInit.js
Executable file
File diff suppressed because it is too large
Load Diff
115
backend/debtorReportProcessor.js
Executable file
115
backend/debtorReportProcessor.js
Executable 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
969
backend/fileProcessor.js
Executable 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;
|
||||
19
backend/migrate_add_deferred_status.sql
Executable file
19
backend/migrate_add_deferred_status.sql
Executable 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 $$;
|
||||
93
backend/migrate_add_performance_tracking.sql
Executable file
93
backend/migrate_add_performance_tracking.sql
Executable 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 'Миграция завершена: добавлены поля для отслеживания производительности';
|
||||
51
backend/migrate_candidate_events.sql
Executable file
51
backend/migrate_candidate_events.sql
Executable 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);
|
||||
17
backend/migrate_candidate_stage.sql
Executable file
17
backend/migrate_candidate_stage.sql
Executable 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$$;
|
||||
22
backend/migrate_company_settings.sql
Executable file
22
backend/migrate_company_settings.sql
Executable 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;
|
||||
39
backend/migrate_counterparty_checks.sql
Executable file
39
backend/migrate_counterparty_checks.sql
Executable 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';
|
||||
149
backend/migrate_development_module.sql
Executable file
149
backend/migrate_development_module.sql
Executable 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();
|
||||
40
backend/migrate_doma_mappings.sql
Executable file
40
backend/migrate_doma_mappings.sql
Executable 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);
|
||||
18
backend/migrate_legal_case_documents.sql
Executable file
18
backend/migrate_legal_case_documents.sql
Executable 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 'Документы по судебным делам: претензия, иск, решение, ИЛ, постановление ИП';
|
||||
12
backend/migrate_legal_fssp_enforcement.sql
Executable file
12
backend/migrate_legal_fssp_enforcement.sql
Executable 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
140
backend/migrate_legal_module.sql
Executable 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 'Комментарии к судебным делам';
|
||||
9
backend/migrate_legal_penalties.sql
Executable file
9
backend/migrate_legal_penalties.sql
Executable 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 'Дата начала просрочки оплаты';
|
||||
25
backend/migrate_nps_building_stats.sql
Executable file
25
backend/migrate_nps_building_stats.sql
Executable 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
40
backend/migrate_nps_surveys.sql
Executable 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);
|
||||
131
backend/migrate_pipeline_automation.sql
Executable file
131
backend/migrate_pipeline_automation.sql
Executable 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
107
backend/migrate_pr_nps.sql
Executable 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;
|
||||
90
backend/migrate_pre_trial_work.sql
Executable file
90
backend/migrate_pre_trial_work.sql
Executable 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 'Обещанные должниками оплаты с отслеживанием выполнения';
|
||||
60
backend/migrate_resident_report_data.sql
Executable file
60
backend/migrate_resident_report_data.sql
Executable 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, январь)';
|
||||
42
backend/migrate_salary_history.sql
Executable file
42
backend/migrate_salary_history.sql
Executable 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';
|
||||
124
backend/migrate_training_module.sql
Executable file
124
backend/migrate_training_module.sql
Executable 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 программы';
|
||||
66
backend/migrations/add_applications_manual_fields.sql
Executable file
66
backend/migrations/add_applications_manual_fields.sql
Executable 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 $$;
|
||||
17
backend/migrations/add_closing_docs_files_to_payment_invoices.sql
Executable file
17
backend/migrations/add_closing_docs_files_to_payment_invoices.sql
Executable 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 $$;
|
||||
|
||||
11
backend/migrations/add_event_to_payment_invoices.sql
Executable file
11
backend/migrations/add_event_to_payment_invoices.sql
Executable 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';
|
||||
3
backend/migrations/add_expected_return_date_repair_requests.sql
Executable file
3
backend/migrations/add_expected_return_date_repair_requests.sql
Executable file
@@ -0,0 +1,3 @@
|
||||
-- Ожидаемая дата возврата из ремонта
|
||||
ALTER TABLE office_repair_requests
|
||||
ADD COLUMN IF NOT EXISTS expected_return_date DATE;
|
||||
4
backend/migrations/add_image_to_scheduled_posts.sql
Executable file
4
backend/migrations/add_image_to_scheduled_posts.sql
Executable 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 изображения для поста';
|
||||
8
backend/migrations/add_inventory_to_districts.sql
Executable file
8
backend/migrations/add_inventory_to_districts.sql
Executable 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);
|
||||
62
backend/migrations/add_item_type_to_payment_invoices.sql
Executable file
62
backend/migrations/add_item_type_to_payment_invoices.sql
Executable 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 $$;
|
||||
7
backend/migrations/add_outage_fields.sql
Executable file
7
backend/migrations/add_outage_fields.sql
Executable 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;
|
||||
9
backend/migrations/add_payment_ref_and_is_cash_to_payment_invoices.sql
Executable file
9
backend/migrations/add_payment_ref_and_is_cash_to_payment_invoices.sql
Executable 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 'Причина отмены';
|
||||
6
backend/migrations/add_plan_item_to_payment_invoices.sql
Executable file
6
backend/migrations/add_plan_item_to_payment_invoices.sql
Executable 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;
|
||||
8
backend/migrations/add_pr_events_location_place.sql
Executable file
8
backend/migrations/add_pr_events_location_place.sql
Executable 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';
|
||||
5
backend/migrations/add_repair_request_statuses.sql
Executable file
5
backend/migrations/add_repair_request_statuses.sql
Executable 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';
|
||||
9
backend/migrations/add_repair_status_fields.sql
Executable file
9
backend/migrations/add_repair_status_fields.sql
Executable 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);
|
||||
12
backend/migrations/add_report_type.sql
Executable file
12
backend/migrations/add_report_type.sql
Executable 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 (другие)';
|
||||
7
backend/migrations/add_report_type_balance_sheet_76.sql
Executable file
7
backend/migrations/add_report_type_balance_sheet_76.sql
Executable 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';
|
||||
10
backend/migrations/add_scheduled_date_to_post_topics.sql
Executable file
10
backend/migrations/add_scheduled_date_to_post_topics.sql
Executable 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 'Дата планируемой публикации поста по этой теме';
|
||||
5
backend/migrations/add_task_id_to_work_photos.sql
Executable file
5
backend/migrations/add_task_id_to_work_photos.sql
Executable 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);
|
||||
30
backend/migrations/add_user_profile_fields.sql
Executable file
30
backend/migrations/add_user_profile_fields.sql
Executable 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 'Расширяемые настройки и предпочтения пользователя';
|
||||
25
backend/migrations/create_application_comments_history.sql
Executable file
25
backend/migrations/create_application_comments_history.sql
Executable 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);
|
||||
21
backend/migrations/create_building_personal_accounts.sql
Executable file
21
backend/migrations/create_building_personal_accounts.sql
Executable 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;
|
||||
19
backend/migrations/create_company_news.sql
Executable file
19
backend/migrations/create_company_news.sql
Executable 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 'Новости компании: черновики и публикации с уведомлением по отделам/сотрудникам';
|
||||
18
backend/migrations/create_debtor_report_data.sql
Executable file
18
backend/migrations/create_debtor_report_data.sql
Executable 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 'Строки отчёта по задолженности: лицевой счёт, ФИО, адрес, месяцы долга, сумма';
|
||||
18
backend/migrations/create_employee_districts.sql
Executable file
18
backend/migrations/create_employee_districts.sql
Executable 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;
|
||||
14
backend/migrations/create_employee_responsibility.sql
Executable file
14
backend/migrations/create_employee_responsibility.sql
Executable 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);
|
||||
42
backend/migrations/create_expense_directory.sql
Executable file
42
backend/migrations/create_expense_directory.sql
Executable 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, ... }';
|
||||
16
backend/migrations/create_finance_accounts.sql
Executable file
16
backend/migrations/create_finance_accounts.sql
Executable 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 'Банковские счета и кошельки (наличка) для отображения остатков в календаре оплат';
|
||||
21
backend/migrations/create_office_equipment_history.sql
Executable file
21
backend/migrations/create_office_equipment_history.sql
Executable 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);
|
||||
16
backend/migrations/create_outages.sql
Executable file
16
backend/migrations/create_outages.sql
Executable 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);
|
||||
28
backend/migrations/create_payment_calendar_entries.sql
Executable file
28
backend/migrations/create_payment_calendar_entries.sql
Executable 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 'Записи платежного календаря: расходы и поступления (по счету, без счета, наличка)';
|
||||
18
backend/migrations/create_payment_categories.sql
Executable file
18
backend/migrations/create_payment_categories.sql
Executable 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 'Справочник статей доходов и расходов для платежного календаря';
|
||||
82
backend/migrations/create_payment_invoices.sql
Executable file
82
backend/migrations/create_payment_invoices.sql
Executable 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}]';
|
||||
12
backend/migrations/create_positions.sql
Executable file
12
backend/migrations/create_positions.sql
Executable 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 — руководящая должность';
|
||||
19
backend/migrations/create_pr_attraction_actions.sql
Executable file
19
backend/migrations/create_pr_attraction_actions.sql
Executable 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 'Действия привлечения: рассылка, мероприятие, пост и т.д., с приростом подписчиков';
|
||||
52
backend/migrations/create_pr_events.sql
Executable file
52
backend/migrations/create_pr_events.sql
Executable 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 'Фотоотчёт по мероприятию';
|
||||
21
backend/migrations/create_pr_post_topics.sql
Executable file
21
backend/migrations/create_pr_post_topics.sql
Executable 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 'График публикации (календарь тем) - план публикаций на месяц. По этим темам создаются посты с контентом.';
|
||||
23
backend/migrations/create_pr_scheduled_posts.sql
Executable file
23
backend/migrations/create_pr_scheduled_posts.sql
Executable 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 'Отложенные посты для одобрения перед публикацией';
|
||||
28
backend/migrations/create_pr_smm_channels.sql
Executable file
28
backend/migrations/create_pr_smm_channels.sql
Executable 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 'Фиксации количества подписчиков по дате для истории';
|
||||
33
backend/migrations/create_report_76_tables.sql
Executable file
33
backend/migrations/create_report_76_tables.sql
Executable 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): фильтр по дому, отображение в карточке дома';
|
||||
24
backend/migrations/create_user_roles.sql
Executable file
24
backend/migrations/create_user_roles.sql
Executable 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';
|
||||
27
backend/migrations/development_audits_status_and_inspection.sql
Executable file
27
backend/migrations/development_audits_status_and_inspection.sql
Executable 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 $$;
|
||||
|
||||
-- Индекс сложности дома (0–100)
|
||||
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);
|
||||
7
backend/migrations/development_oss_agenda_and_votes.sql
Executable file
7
backend/migrations/development_oss_agenda_and_votes.sql
Executable 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 '{}';
|
||||
17
backend/migrations/development_pipeline_add_analysis.sql
Executable file
17
backend/migrations/development_pipeline_add_analysis.sql
Executable 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'
|
||||
));
|
||||
31
backend/migrations/development_pipeline_new_stages.sql
Executable file
31
backend/migrations/development_pipeline_new_stages.sql
Executable 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';
|
||||
15
backend/migrations/update_building_financial_data_unique.sql
Executable file
15
backend/migrations/update_building_financial_data_unique.sql
Executable 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
201
backend/notificationService.js
Executable 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
3494
backend/package-lock.json
generated
Executable file
File diff suppressed because it is too large
Load Diff
25
backend/package.json
Executable file
25
backend/package.json
Executable 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
153
backend/paymentInvoiceWorkflow.js
Executable 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
561
backend/pipelineAutomation.js
Executable 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
30
backend/read_act_xlsx.js
Executable 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
139
backend/reviewApiService.js
Executable 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: рейтинг 0–5, количество отзывов).
|
||||
* Полный список текстов отзывов — по отдельному согласованию с 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
1088
backend/reviewParser.js
Executable file
File diff suppressed because it is too large
Load Diff
450
backend/routes/buildings.js
Executable file
450
backend/routes/buildings.js
Executable 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
410
backend/salaryProcessor.js
Executable 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
919
backend/schema.sql
Executable 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
24107
backend/server.js
Executable file
File diff suppressed because it is too large
Load Diff
4
backend/templates/accounts.csv
Executable file
4
backend/templates/accounts.csv
Executable file
@@ -0,0 +1,4 @@
|
||||
building_id,apartment_number
|
||||
b-1,1
|
||||
b-1,2
|
||||
b-2,1
|
||||
|
3
backend/templates/buildings.csv
Executable file
3
backend/templates/buildings.csv
Executable file
@@ -0,0 +1,3 @@
|
||||
id,address,district_id
|
||||
b-1,ул. Примерная, 1,d-1
|
||||
b-2,ул. Примерная, 2,d-1
|
||||
|
3
backend/templates/districts.csv
Executable file
3
backend/templates/districts.csv
Executable file
@@ -0,0 +1,3 @@
|
||||
id,name,manager_name
|
||||
d-1,Участок 1,Иванов И.И.
|
||||
d-2,Участок 2,Петров П.П.
|
||||
|
3
backend/templates/employees.csv
Executable file
3
backend/templates/employees.csv
Executable 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
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
Источники локального самоуправления
|
||||
Отчеты администрации городов и районов – для анализа ключевых проблем и задач.
|
||||
Протоколы совещаний, дорожные карты цифровизации.
|
||||
Публичные обращения граждан
|
||||
Обращения граждан на официальных сайтах (например, раздел "Обращения" на сайте правительства РБ).
|
||||
Форумы и соцсети (Vk, Telegram-каналы) – анализ наиболее популярных тем и проблем.
|
||||
Научные и исследовательские публикации
|
||||
Работы институтов и ВУЗов Башкортостана (БГУ, УГАТУ, БГПУ).
|
||||
Научные статьи о цифровизации госуправления.
|
||||
|
||||
|
||||
docker run -d --name openwebui -p 3000:3000 ghcr.io/open-webui/open-webui:main
|
||||
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
python test_diarization.py
|
||||
|
||||
import torch.nn.functional as F
|
||||
|
||||
# Функция для вычисления сходства голосов
|
||||
def compare_embeddings(embedding1, embedding2):
|
||||
embedding1 = torch.tensor(embedding1.data).mean(dim=0)
|
||||
embedding2 = torch.tensor(embedding2.data).mean(dim=0)
|
||||
|
||||
# Используем косинусное сходство
|
||||
similarity = F.cosine_similarity(embedding1.unsqueeze(0), embedding2.unsqueeze(0))
|
||||
return similarity.item()
|
||||
|
||||
ffmpeg -i dataset/Arsen.wav -ac 1 -ar 16000 dataset/Arsen1.wav
|
||||
ffmpeg -i dataset/Sany.wav -ac 1 -ar 16000 dataset/Sany1.wav
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
from pyannote.audio import Pipeline
|
||||
|
||||
# Загрузим модель
|
||||
pipeline = Pipeline.from_pretrained("pyannote/speaker-diarization-3.1")
|
||||
|
||||
# Запустим диаризацию на тестовом файле
|
||||
diarization = pipeline("test.mp3")
|
||||
|
||||
# Выведем результат
|
||||
for segment, _, speaker in diarization.itertracks(yield_label=True):
|
||||
print(f"{segment.start:.1f}s - {segment.end:.1f}s: {speaker}")
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
Источники локального самоуправления
|
||||
Отчеты администрации городов и районов – для анализа ключевых проблем и задач.
|
||||
Протоколы совещаний, дорожные карты цифровизации.
|
||||
Публичные обращения граждан
|
||||
Обращения граждан на официальных сайтах (например, раздел "Обращения" на сайте правительства РБ).
|
||||
Форумы и соцсети (Vk, Telegram-каналы) – анализ наиболее популярных тем и проблем.
|
||||
Научные и исследовательские публикации
|
||||
Работы институтов и ВУЗов Башкортостана (БГУ, УГАТУ, БГПУ).
|
||||
Научные статьи о цифровизации госуправления.
|
||||
|
||||
|
||||
docker run -d --name openwebui -p 3000:3000 ghcr.io/open-webui/open-webui:main
|
||||
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
python test_diarization.py
|
||||
|
||||
import torch.nn.functional as F
|
||||
|
||||
# Функция для вычисления сходства голосов
|
||||
def compare_embeddings(embedding1, embedding2):
|
||||
embedding1 = torch.tensor(embedding1.data).mean(dim=0)
|
||||
embedding2 = torch.tensor(embedding2.data).mean(dim=0)
|
||||
|
||||
# Используем косинусное сходство
|
||||
similarity = F.cosine_similarity(embedding1.unsqueeze(0), embedding2.unsqueeze(0))
|
||||
return similarity.item()
|
||||
|
||||
ffmpeg -i dataset/Arsen.wav -ac 1 -ar 16000 dataset/Arsen1.wav
|
||||
ffmpeg -i dataset/Sany.wav -ac 1 -ar 16000 dataset/Sany1.wav
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user