Files
mkd/backend/routes/buildings.js

451 lines
18 KiB
JavaScript
Raw Normal View History

2026-02-04 00:17:04 +05:00
'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;