From b689ed70dcc0e4253d6257f4a6983e9de2464309 Mon Sep 17 00:00:00 2001 From: rodielavender Date: Wed, 13 May 2026 23:22:39 +0000 Subject: [PATCH] =?UTF-8?q?perf(backend):=20Promise.all=20=D0=B4=D0=B0?= =?UTF-8?q?=D1=88=D0=B1=D0=BE=D1=80=D0=B4=D0=B0=20+=20=D0=BF=D0=B0=D0=B3?= =?UTF-8?q?=D0=B8=D0=BD=D0=B0=D1=86=D0=B8=D1=8F=20/api/clients=20=D0=B8=20?= =?UTF-8?q?/api/cases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/__tests__/clients.test.js | 37 +++++ server/__tests__/crm-modules.test.js | 30 ++++ server/__tests__/dashboard.test.js | 15 ++ server/controllers/clientController.js | 17 +- server/controllers/crmModules.js | 32 +++- .../controllers/officeDashboardController.js | 146 +++++++++--------- server/models/client.js | 40 +++-- 7 files changed, 229 insertions(+), 88 deletions(-) diff --git a/server/__tests__/clients.test.js b/server/__tests__/clients.test.js index 91ff07a..cd5b2fd 100644 --- a/server/__tests__/clients.test.js +++ b/server/__tests__/clients.test.js @@ -88,6 +88,43 @@ describe('GET /api/clients', () => { expect(listB.body.data).toHaveLength(1); expect(listB.body.data[0].name).toBe('Office B Client'); }); + + it('supports pagination via ?page= and ?page_size= with metadata in body', async () => { + const { token } = await registerLawyerWithOffice(app); + + for (let i = 0; i < 5; i++) { + await request(app) + .post('/api/clients') + .set('Authorization', `Bearer ${token}`) + .send({ name: `Client ${i + 1}` }); + } + + const page1 = await request(app) + .get('/api/clients?page=1&page_size=2') + .set('Authorization', `Bearer ${token}`); + expect(page1.status).toBe(200); + expect(page1.body.data).toHaveLength(2); + expect(page1.body.total).toBe(5); + expect(page1.body.page).toBe(1); + expect(page1.body.page_size).toBe(2); + + const page2 = await request(app) + .get('/api/clients?page=2&page_size=2') + .set('Authorization', `Bearer ${token}`); + expect(page2.body.data).toHaveLength(2); + + const page3 = await request(app) + .get('/api/clients?page=3&page_size=2') + .set('Authorization', `Bearer ${token}`); + expect(page3.body.data).toHaveLength(1); + + const allIds = [ + ...page1.body.data.map((c) => c.id), + ...page2.body.data.map((c) => c.id), + ...page3.body.data.map((c) => c.id), + ]; + expect(new Set(allIds).size).toBe(5); + }); }); describe('PUT /api/clients/:id', () => { diff --git a/server/__tests__/crm-modules.test.js b/server/__tests__/crm-modules.test.js index e758526..44e0502 100644 --- a/server/__tests__/crm-modules.test.js +++ b/server/__tests__/crm-modules.test.js @@ -56,6 +56,36 @@ describe('POST /api/cases', () => { }); }); +describe('GET /api/cases pagination', () => { + it('returns paginated cases with total when ?page=&page_size=', async () => { + const { authHeaders } = await registerDirectorWithOffice(app); + + for (let i = 0; i < 4; i++) { + await request(app) + .post('/api/cases') + .set(authHeaders) + .send({ title: `Дело #${i + 1}`, status: 'in_progress' }); + } + + const page1 = await request(app) + .get('/api/cases?page=1&page_size=2') + .set(authHeaders); + expect(page1.status).toBe(200); + expect(page1.body.data).toHaveLength(2); + expect(page1.body.total).toBe(4); + expect(page1.body.page).toBe(1); + expect(page1.body.page_size).toBe(2); + + const page2 = await request(app) + .get('/api/cases?page=2&page_size=2') + .set(authHeaders); + expect(page2.body.data).toHaveLength(2); + + const all = [...page1.body.data, ...page2.body.data]; + expect(new Set(all.map((r) => r.id)).size).toBe(4); + }); +}); + describe('POST /api/expenses', () => { it('creates an expense and persists it', async () => { const { authHeaders, officeId, user } = await registerDirectorWithOffice(app); diff --git a/server/__tests__/dashboard.test.js b/server/__tests__/dashboard.test.js index 3fed01f..7a993c7 100644 --- a/server/__tests__/dashboard.test.js +++ b/server/__tests__/dashboard.test.js @@ -65,4 +65,19 @@ describe('office dashboard + plan', () => { expect(Number(get.body.data.daily_plan_weekday)).toBe(10000); expect(Number(get.body.data.period_plan_amount)).toBe(300000); }); + + it('GET /api/office/:officeId/dashboard — gzip-сжатый ответ при наличии Accept-Encoding', async () => { + const dir = await registerDirectorWithOffice(app); + + const res = await request(app) + .get(`/api/office/${dir.officeId}/dashboard`) + .set(dir.authHeaders) + .set('Accept-Encoding', 'gzip'); + + expect(res.status).toBe(200); + // Если payload > threshold (1024 байт) — должен быть gzip. + // При маленьком пустом дашборде compression может не срабатывать, + // поэтому просто проверяем, что middleware не ломает ответ. + expect(res.body.success).toBe(true); + }); }); diff --git a/server/controllers/clientController.js b/server/controllers/clientController.js index 6bb283a..a18b06e 100644 --- a/server/controllers/clientController.js +++ b/server/controllers/clientController.js @@ -18,8 +18,23 @@ const clientController = { return res.json({ success: true, data: [] }); } + // Опциональная пагинация: ?page=1&page_size=50 (page_size capped at 200) + const page = parseInt(req.query.page, 10); + const pageSize = Math.min(parseInt(req.query.page_size, 10) || 50, 200); + + if (page > 0) { + const result = await Client.getAllByOffice(officeId, { page, pageSize }); + return res.json({ + success: true, + data: result.items, + total: result.total, + page, + page_size: pageSize, + }); + } + const clients = await Client.getAllByOffice(officeId); - + res.json({ success: true, data: clients diff --git a/server/controllers/crmModules.js b/server/controllers/crmModules.js index ea378e6..b3b5d0b 100644 --- a/server/controllers/crmModules.js +++ b/server/controllers/crmModules.js @@ -25,15 +25,31 @@ const cases = { async list(req, res) { try { const officeId = req.params.officeId || req.user.office_id; + + // Опциональная пагинация: ?page=1&page_size=50 (page_size capped at 200) + const page = parseInt(req.query.page, 10); + const pageSize = Math.min(parseInt(req.query.page_size, 10) || 50, 200); + + const baseFrom = ` + FROM cases ca + LEFT JOIN clients cl ON cl.id = ca.client_id + LEFT JOIN employees e ON e.id = ca.employee_id + WHERE ca.office_id = ? + `; + const select = `SELECT ca.*, cl.name AS client_name, CONCAT(e.first_name, ' ', e.last_name) AS employee_name`; + + if (page > 0) { + const [[{ total }]] = await db.query(`SELECT COUNT(*) AS total ${baseFrom}`, [officeId]); + const offset = (page - 1) * pageSize; + const [rows] = await db.query( + `${select} ${baseFrom} ORDER BY ca.created_at DESC LIMIT ? OFFSET ?`, + [officeId, pageSize, offset] + ); + return ok(res, rows, { total, page, page_size: pageSize }); + } + const [rows] = await db.query( - `SELECT ca.*, - cl.name AS client_name, - CONCAT(e.first_name, ' ', e.last_name) AS employee_name - FROM cases ca - LEFT JOIN clients cl ON cl.id = ca.client_id - LEFT JOIN employees e ON e.id = ca.employee_id - WHERE ca.office_id = ? - ORDER BY ca.created_at DESC`, + `${select} ${baseFrom} ORDER BY ca.created_at DESC`, [officeId] ); return ok(res, rows); diff --git a/server/controllers/officeDashboardController.js b/server/controllers/officeDashboardController.js index e4cfe69..9f9063a 100644 --- a/server/controllers/officeDashboardController.js +++ b/server/controllers/officeDashboardController.js @@ -103,80 +103,86 @@ const officeDashboardController = { const { from, to, today, label } = resolvePeriod(req); - // Fact: paid_amount sum on contract_date — only "оплаченные" договоры (paid_amount > 0). - // contract_date is DATE; we use BETWEEN. - const [factRows] = await db.query( - `SELECT - COALESCE(SUM(CASE WHEN c.contract_date = ? THEN c.paid_amount ELSE 0 END), 0) AS day_fact, - COALESCE(SUM(CASE WHEN c.contract_date BETWEEN ? AND ? THEN c.paid_amount ELSE 0 END), 0) AS period_fact - FROM contracts c - JOIN employees e ON e.id = c.id_employee - WHERE e.office_id = ? AND c.paid_amount > 0`, - [today, from, to, officeId] - ); + // Все 5 запросов независимы между собой — гоняем параллельно через Promise.all, + // чтобы суммарный latency был ~max(query), а не sum(query). + const [ + [factRows], + [refundRows], + [planRows], + [lawyersRows], + [lawyerRefundRows], + ] = await Promise.all([ + // Fact: paid_amount sum on contract_date — only "оплаченные" договоры (paid_amount > 0). + db.query( + `SELECT + COALESCE(SUM(CASE WHEN c.contract_date = ? THEN c.paid_amount ELSE 0 END), 0) AS day_fact, + COALESCE(SUM(CASE WHEN c.contract_date BETWEEN ? AND ? THEN c.paid_amount ELSE 0 END), 0) AS period_fact + FROM contracts c + JOIN employees e ON e.id = c.id_employee + WHERE e.office_id = ? AND c.paid_amount > 0`, + [today, from, to, officeId] + ), + // Confirmed refunds: subtract from fact on the day/period when refund was confirmed + db.query( + `SELECT + COALESCE(SUM(CASE WHEN DATE(c.refund_confirmed_at) = ? THEN c.refund_amount ELSE 0 END), 0) AS day_refund, + COALESCE(SUM(CASE WHEN DATE(c.refund_confirmed_at) BETWEEN ? AND ? THEN c.refund_amount ELSE 0 END), 0) AS period_refund + FROM contracts c + JOIN employees e ON e.id = c.id_employee + WHERE e.office_id = ? AND c.refund_confirmed = 1 AND c.refund_amount > 0`, + [today, from, to, officeId] + ), + // Plan: latest record covering this period; otherwise latest record overall. + db.query( + `SELECT id, daily_plan_weekday, daily_plan_weekend, period_plan_amount, period_start, period_end + FROM office_plans + WHERE office_id = ? + ORDER BY (period_start <= ? AND period_end >= ?) DESC, updated_at DESC + LIMIT 1`, + [officeId, to, from] + ), + // Lawyers cash table — менеджеры, ОКК, юристы/адвокаты/представители, только paid_amount > 0 + db.query( + `SELECT + e.id, + TRIM(CONCAT_WS(' ', e.last_name, e.first_name, e.middle_name)) AS full_name, + e.position, + COALESCE(SUM(CASE WHEN c.contract_date = ? THEN c.paid_amount ELSE 0 END), 0) AS today_cash, + COALESCE(SUM(CASE WHEN c.contract_date BETWEEN ? AND ? THEN c.paid_amount ELSE 0 END), 0) AS period_cash + FROM employees e + LEFT JOIN users u ON u.email = e.email + LEFT JOIN contracts c ON c.id_employee = e.id AND c.paid_amount > 0 + WHERE e.office_id = ? + AND ( + LOWER(e.position) LIKE '%юрист%' + OR LOWER(e.position) LIKE '%адвокат%' + OR LOWER(e.position) LIKE '%менеджер%' + OR LOWER(e.position) LIKE '%окк%' + OR LOWER(e.position) LIKE '%контрол%' + OR LOWER(e.position) LIKE '%представит%' + OR u.role IN ('lawyer', 'manager', 'okk', 'representative') + ) + GROUP BY e.id, e.last_name, e.first_name, e.middle_name, e.position + ORDER BY period_cash DESC, today_cash DESC, e.last_name ASC`, + [today, from, to, officeId] + ), + // Per-employee confirmed refunds for lawyers_cash subtraction + db.query( + `SELECT + c.id_employee, + COALESCE(SUM(CASE WHEN DATE(c.refund_confirmed_at) = ? THEN c.refund_amount ELSE 0 END), 0) AS day_refund, + COALESCE(SUM(CASE WHEN DATE(c.refund_confirmed_at) BETWEEN ? AND ? THEN c.refund_amount ELSE 0 END), 0) AS period_refund + FROM contracts c + JOIN employees e ON e.id = c.id_employee + WHERE e.office_id = ? AND c.refund_confirmed = 1 AND c.refund_amount > 0 + GROUP BY c.id_employee`, + [today, from, to, officeId] + ), + ]); - // Confirmed refunds: subtract from fact on the day/period when refund was confirmed - const [refundRows] = await db.query( - `SELECT - COALESCE(SUM(CASE WHEN DATE(c.refund_confirmed_at) = ? THEN c.refund_amount ELSE 0 END), 0) AS day_refund, - COALESCE(SUM(CASE WHEN DATE(c.refund_confirmed_at) BETWEEN ? AND ? THEN c.refund_amount ELSE 0 END), 0) AS period_refund - FROM contracts c - JOIN employees e ON e.id = c.id_employee - WHERE e.office_id = ? AND c.refund_confirmed = 1 AND c.refund_amount > 0`, - [today, from, to, officeId] - ); const day_fact = Number(factRows[0]?.day_fact || 0) - Number(refundRows[0]?.day_refund || 0); const period_fact = Number(factRows[0]?.period_fact || 0) - Number(refundRows[0]?.period_refund || 0); - - // Plan: latest record covering this period; otherwise latest record overall. - const [planRows] = await db.query( - `SELECT id, daily_plan_weekday, daily_plan_weekend, period_plan_amount, period_start, period_end - FROM office_plans - WHERE office_id = ? - ORDER BY (period_start <= ? AND period_end >= ?) DESC, updated_at DESC - LIMIT 1`, - [officeId, to, from] - ); const plan = planRows[0] || null; - - // Lawyers cash table — менеджеры, ОКК, юристы/адвокаты/представители, только paid_amount > 0 - const [lawyersRows] = await db.query( - `SELECT - e.id, - TRIM(CONCAT_WS(' ', e.last_name, e.first_name, e.middle_name)) AS full_name, - e.position, - COALESCE(SUM(CASE WHEN c.contract_date = ? THEN c.paid_amount ELSE 0 END), 0) AS today_cash, - COALESCE(SUM(CASE WHEN c.contract_date BETWEEN ? AND ? THEN c.paid_amount ELSE 0 END), 0) AS period_cash - FROM employees e - LEFT JOIN users u ON u.email = e.email - LEFT JOIN contracts c ON c.id_employee = e.id AND c.paid_amount > 0 - WHERE e.office_id = ? - AND ( - LOWER(e.position) LIKE '%юрист%' - OR LOWER(e.position) LIKE '%адвокат%' - OR LOWER(e.position) LIKE '%менеджер%' - OR LOWER(e.position) LIKE '%окк%' - OR LOWER(e.position) LIKE '%контрол%' - OR LOWER(e.position) LIKE '%представит%' - OR u.role IN ('lawyer', 'manager', 'okk', 'representative') - ) - GROUP BY e.id, e.last_name, e.first_name, e.middle_name, e.position - ORDER BY period_cash DESC, today_cash DESC, e.last_name ASC`, - [today, from, to, officeId] - ); - - // Per-employee confirmed refunds for lawyers_cash subtraction - const [lawyerRefundRows] = await db.query( - `SELECT - c.id_employee, - COALESCE(SUM(CASE WHEN DATE(c.refund_confirmed_at) = ? THEN c.refund_amount ELSE 0 END), 0) AS day_refund, - COALESCE(SUM(CASE WHEN DATE(c.refund_confirmed_at) BETWEEN ? AND ? THEN c.refund_amount ELSE 0 END), 0) AS period_refund - FROM contracts c - JOIN employees e ON e.id = c.id_employee - WHERE e.office_id = ? AND c.refund_confirmed = 1 AND c.refund_amount > 0 - GROUP BY c.id_employee`, - [today, from, to, officeId] - ); const refundByEmp = new Map(); lawyerRefundRows.forEach(r => refundByEmp.set(r.id_employee, { day: Number(r.day_refund || 0), diff --git a/server/models/client.js b/server/models/client.js index c813e4d..a220144 100644 --- a/server/models/client.js +++ b/server/models/client.js @@ -2,21 +2,43 @@ const db = require('../db'); class Client { /** - * Получить всех клиентов офиса (по office_id в clients) + * Получить всех клиентов офиса (по office_id в clients). + * Поддерживает опциональную пагинацию: { page, pageSize } — если оба > 0, + * возвращает { items, total, page, pageSize }. Без пагинации возвращает + * массив (обратная совместимость). */ - static async getAllByOffice(officeId) { + static async getAllByOffice(officeId, options = {}) { try { - const query = ` - SELECT cl.*, - COUNT(DISTINCT c.id) as contracts_count, - COALESCE(SUM(c.amount), 0) as total_spent + const { page, pageSize } = options; + + const baseFrom = ` FROM clients cl LEFT JOIN contracts c ON cl.id = c.id_client WHERE cl.office_id = ? - GROUP BY cl.id - ORDER BY cl.id DESC `; - const [clients] = await db.query(query, [officeId]); + const selectFields = ` + SELECT cl.*, + COUNT(DISTINCT c.id) as contracts_count, + COALESCE(SUM(c.amount), 0) as total_spent + `; + + if (page > 0 && pageSize > 0) { + const [[{ total }]] = await db.query( + `SELECT COUNT(DISTINCT cl.id) AS total ${baseFrom}`, + [officeId] + ); + const offset = (page - 1) * pageSize; + const [items] = await db.query( + `${selectFields} ${baseFrom} GROUP BY cl.id ORDER BY cl.id DESC LIMIT ? OFFSET ?`, + [officeId, pageSize, offset] + ); + return { items, total, page, pageSize }; + } + + const [clients] = await db.query( + `${selectFields} ${baseFrom} GROUP BY cl.id ORDER BY cl.id DESC`, + [officeId] + ); return clients; } catch (error) { console.error('Error getting clients:', error);