const express = require('express'); const cors = require('cors'); const path = require('path'); const multer = require('multer'); const db = require('./db'); const issueService = require('./issueService'); const app = express(); const PORT = process.env.PORT || 3000; app.use(cors()); app.use(express.json()); const router = express.Router(); router.use(express.static(path.join(__dirname, 'public'))); router.use('/uploads', express.static(path.join(__dirname, 'uploads'))); // Multer storage for issue attachments const storage = multer.diskStorage({ destination: function (req, file, cb) { issueService.ensureDirs(); cb(null, issueService.UPLOADS_DIR); }, filename: function (req, file, cb) { const ext = path.extname(file.originalname); const safeBase = path.basename(file.originalname, ext).replace(/[^a-zA-Z0-9_-]/g, '_'); const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e6); cb(null, `${safeBase}_${uniqueSuffix}${ext}`); } }); const upload = multer({ storage: storage, limits: { fileSize: 25 * 1024 * 1024 }, // 25MB max }); // Ensure giaoban_ghichu table exists async function ensureTable(dbName) { try { await db.query(` CREATE TABLE IF NOT EXISTS current.giaoban_ghichu ( id SERIAL PRIMARY KEY, maba VARCHAR(50) NOT NULL, mabn VARCHAR(20) NOT NULL, loai_danhsach VARCHAR(20) NOT NULL, ghichu TEXT, ngay_capnhat TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT unq_giaoban_item_${dbName ? dbName.replace(/[^a-zA-Z0-9]/g, '_') : 'default'} UNIQUE(maba, loai_danhsach) ); `, [], dbName); } catch (err) { // If permission or table exists, ignore } } // 1. Danh sách database router.get('/api/databases', async (req, res) => { try { const list = [ { id: 'an_omon_full_04052026', name: 'BVĐK Ô Môn - Cần Thơ (33 GB - Đầy đủ khoa phòng chuẩn)' }, { id: 'tuan_dbgovap_full', name: 'BVĐK Gò Vấp - TP.HCM (65 GB - Dữ liệu lớn)' }, { id: 'binh_bvq12_062026052026042026032026022026', name: 'BV Quận 12 - TP.HCM (21 GB)' }, { id: 'bvq12_697', name: 'BV Quận 12 - Bản 697 (17 GB)' }, { id: 'an_dalieu_full_05082026', name: 'BV Da Liễu (31 GB)' }, ]; res.json({ success: true, data: list }); } catch (error) { res.status(500).json({ success: false, message: error.message }); } }); // Helper function to build namkt and thangkt partition filter function getThangNamFilter(fromDateStr, toDateStr, prefix = '') { try { const f = new Date(fromDateStr); const t = new Date(toDateStr); if (isNaN(f.getTime()) || isNaN(t.getTime())) return ''; const thangs = new Set(); const nams = new Set(); let curr = new Date(f.getFullYear(), f.getMonth(), 1); const end = new Date(t.getFullYear(), t.getMonth(), 1); let count = 0; while (curr <= end && count < 24) { thangs.add(String(curr.getMonth() + 1).padStart(2, '0')); nams.add(String(curr.getFullYear())); curr.setMonth(curr.getMonth() + 1); count++; } const p = prefix ? `${prefix}.` : ''; const thangsArr = Array.from(thangs).map(m => `'${m}'`).join(','); const namsArr = Array.from(nams).map(y => `'${y}'`).join(','); return ` AND ${p}namkt IN (${namsArr}) AND ${p}thangkt IN (${thangsArr}) `; } catch (e) { return ''; } } // Helper for Outpatient query async function getOutpatientData(database, fromDate, toDate) { const params = [fromDate, toDate]; const fnKb = getThangNamFilter(fromDate, toDate, 'kb'); const fnLs = getThangNamFilter(fromDate, toDate, 'ls'); const fnNone = getThangNamFilter(fromDate, toDate, ''); // A. Thống kê theo Khoa khám bệnh const sqlKhoa = ` SELECT COALESCE(dv.madv, 'KHAC') AS madv, COALESCE(dv.tendv, 'Khoa khác') AS tendv, COUNT(kb.makb) AS tong_kham, SUM(CASE WHEN dt.bhyt IN (1, 2) THEN 1 ELSE 0 END) AS bhyt, SUM(CASE WHEN dt.bhyt IN (0, 4) THEN 1 ELSE 0 END) AS thuphi, SUM(CASE WHEN kb.dakham = 6 THEN 1 ELSE 0 END) AS chuyenvien FROM current.khambenh kb LEFT JOIN current.psdangky dk ON kb.mabn = dk.mabn AND kb.makb = dk.makb AND COALESCE(dk.xoa, 0) = 0 LEFT JOIN current.dmdoituong dt ON dk.madt = dt.madt LEFT JOIN current.dmdonvi dv ON kb.madv = dv.madv WHERE kb.ngaykcb BETWEEN $1 AND $2 ${fnKb} AND COALESCE(kb.xoa, 0) = 0 AND COALESCE(kb.dakham, 0) != 0 GROUP BY dv.madv, dv.tendv ORDER BY tong_kham DESC; `; // B. Bệnh án ngoại trú (Mới vs Tái khám) const sqlBANT = ` SELECT SUM(CASE WHEN COALESCE(dk.taikham, 0) = 0 THEN 1 ELSE 0 END) AS moi, SUM(CASE WHEN COALESCE(dk.taikham, 0) = 1 THEN 1 ELSE 0 END) AS taikham, COUNT(*) AS tong FROM current.bnnoitru nt JOIN current.psdangky dk ON nt.mabn = dk.mabn AND nt.makb = dk.makb AND COALESCE(dk.xoa, 0) = 0 WHERE nt.namvien = 0 AND COALESCE(nt.xoa, 0) = 0 AND nt.ngayvv BETWEEN $1 AND $2; `; // C. Chuyên khoa YDCT const sqlYDCT = ` SELECT SUM(CASE WHEN cls.tencls ILIKE '%Điện châm%' THEN ls.soluong ELSE 0 END) AS dien_cham, SUM(CASE WHEN cls.tencls ILIKE '%Vật lý trị liệu%' OR cls.tencls ILIKE '%VLTL%' OR cls.macls IN ('DI131','DI135') THEN ls.soluong ELSE 0 END) AS vltl, SUM(CASE WHEN cls.tencls ILIKE '%Sắc thuốc%' THEN ls.soluong ELSE 0 END) AS sac_thuoc, SUM(CASE WHEN cls.tencls ILIKE '%Cấy chỉ%' OR cls.tencls ILIKE '%Chôn chỉ%' THEN ls.soluong ELSE 0 END) AS cay_chi FROM current.chidinhcls ls JOIN current.dmcls cls ON ls.macls = cls.macls WHERE ls.ngaykcb BETWEEN $1 AND $2 ${fnLs} AND COALESCE(ls.xoa, 0) = 0; `; // D. Cận lâm sàng const sqlCLS = ` SELECT cls.kho, cls.maloai, COALESCE(loaicls.tenloai, cls.maloai) AS tenloai, COUNT(DISTINCT ls.makb) AS so_bn, COUNT(*) AS so_luot FROM current.chidinhcls ls JOIN current.dmcls cls ON ls.macls = cls.macls LEFT JOIN current.dmloaicls loaicls ON cls.maloai = loaicls.maloai WHERE ls.ngaykcb BETWEEN $1 AND $2 ${fnLs} AND COALESCE(ls.xoa, 0) = 0 AND cls.kho IN ('XN', 'HA', 'CN') GROUP BY cls.kho, cls.maloai, loaicls.tenloai ORDER BY cls.kho, count(*) DESC; `; // E. Top 10 chẩn đoán const sqlTop10 = ` SELECT maicd, kqcdoan, COUNT(*) AS sl FROM current.khambenh WHERE ngaykcb BETWEEN $1 AND $2 ${fnNone} AND COALESCE(xoa, 0) = 0 AND maicd IS NOT NULL AND maicd != '' GROUP BY maicd, kqcdoan ORDER BY sl DESC LIMIT 10; `; // F. Tiêm ngừa const sqlTiemNgua = ` SELECT COUNT(kb.makb) AS sl FROM current.khambenh kb LEFT JOIN current.dmphong ph ON kb.maphong = ph.maphong LEFT JOIN current.dmdonvi dv ON kb.madv = dv.madv WHERE kb.ngaykcb BETWEEN $1 AND $2 ${fnKb} AND COALESCE(kb.xoa, 0) = 0 AND COALESCE(kb.dakham, 0) != 0 AND ( COALESCE(ph.loai_bc_giaoban, 0) = 2 OR ph.maphong = 'TN' OR dv.madv = 'PTN' OR ph.tenphong ILIKE '%Tiêm ngừa%' OR ph.tenphong ILIKE '%Tiêm chủng%' ); `; const [resKhoa, resBANT, resYDCT, resCLS, resTop10, resTiemNgua] = await Promise.all([ db.query(sqlKhoa, params, database), db.query(sqlBANT, params, database), db.query(sqlYDCT, params, database), db.query(sqlCLS, params, database), db.query(sqlTop10, params, database), db.query(sqlTiemNgua, params, database), ]); return { khoa: resKhoa.rows, bant: resBANT.rows[0] || { moi: 0, taikham: 0, tong: 0 }, ydct: resYDCT.rows[0] || { dien_cham: 0, vltl: 0, sac_thuoc: 0, cay_chi: 0 }, cls: resCLS.rows, top10: resTop10.rows, tiemngua: parseInt(resTiemNgua.rows[0]?.sl || 0, 10), }; } // 2. API Báo cáo Giao ban Ngoại trú router.get('/api/giao-ban-ngoai-tru', async (req, res) => { try { const { database, from, to, compareFrom, compareTo } = req.query; const dbName = database || process.env.PG_DATABASE; const fromDate = from || '2026-03-01 00:00:00'; const toDate = to || '2026-03-31 23:59:59'; const currentData = await getOutpatientData(dbName, fromDate, toDate); let compareData = null; if (compareFrom && compareTo) { compareData = await getOutpatientData(dbName, compareFrom, compareTo); } res.json({ success: true, data: { current: currentData, compare: compareData, periodCurrent: { from: fromDate, to: toDate }, periodCompare: compareFrom && compareTo ? { from: compareFrom, to: compareTo } : null, }, }); } catch (error) { console.error('Error in /api/giao-ban-ngoai-tru:', error); res.status(500).json({ success: false, message: error.message }); } }); // Helper for Inpatient query async function getInpatientData(database, fromDate, toDate) { const params = [fromDate, toDate]; const sqlKhoa = ` SELECT dv.madv, dv.tendv, -- 1. Bệnh cũ (tồn đầu kỳ) SUM(CASE WHEN nt.ngayvv < $1 AND (nt.ravien = 0 OR nt.ngayrv >= $1) THEN 1 ELSE 0 END) AS benh_cu, -- 2. Bệnh mới (vào viện trong kỳ) SUM(CASE WHEN nt.ngayvv BETWEEN $1 AND $2 THEN 1 ELSE 0 END) AS benh_moi, -- 3. Xuất viện: SUM(CASE WHEN nt.ngayrv BETWEEN $1 AND $2 AND nt.ravien = 1 AND COALESCE(nt.makq, '01') NOT IN ('04','05','06') THEN 1 ELSE 0 END) AS on_xin_ve, SUM(CASE WHEN nt.ngayrv BETWEEN $1 AND $2 AND (nt.makq IN ('04','06') OR (nt.maxt = '09' AND nt.makq = '04')) THEN 1 ELSE 0 END) AS nang_xin_ve, SUM(CASE WHEN nt.ngayrv BETWEEN $1 AND $2 AND (nt.ravien = 2 OR nt.maxt = '08') THEN 1 ELSE 0 END) AS tron_vien, SUM(CASE WHEN nt.ngayrv BETWEEN $1 AND $2 AND (nt.ravien = 4 OR nt.maxt = '03') THEN 1 ELSE 0 END) AS chuyen_vien, SUM(CASE WHEN nt.ngayrv BETWEEN $1 AND $2 AND (nt.ravien = 3 OR nt.maxt = '06') THEN 1 ELSE 0 END) AS tu_vong, -- 4. Bệnh hiện còn (tồn cuối kỳ) SUM(CASE WHEN nt.ngayvv <= $2 AND (nt.ravien = 0 OR nt.ngayrv > $2) THEN 1 ELSE 0 END) AS benh_hien_con, -- 5. Bệnh theo dõi nặng SUM(CASE WHEN nt.ngayvv <= $2 AND (nt.ravien = 0 OR nt.ngayrv >= $1) AND (COALESCE(nt.benhnang, 0) = 1 OR COALESCE(nt.mabatheodoi, '') != '' OR dv.madv IN ('17','29')) THEN 1 ELSE 0 END) AS benh_theo_doi, -- 6. Chăm sóc Cấp I SUM(CASE WHEN nt.ngayvv <= $2 AND (nt.ravien = 0 OR nt.ngayrv >= $1) AND (nt.chamsoc ILIKE '%cấp I%' OR nt.chamsoc ILIKE '%cấp 1%' OR nt.chamsoc IN ('1', 'CS I', 'CSI')) THEN 1 ELSE 0 END) AS chamsoc_cap1 FROM current.bnnoitru nt JOIN current.dmdonvi dv ON nt.madv = dv.madv WHERE nt.namvien = 1 AND COALESCE(nt.xoa, 0) = 0 AND nt.ngayvv >= ($1::date - INTERVAL '180 days') GROUP BY dv.madv, dv.tendv HAVING SUM(CASE WHEN nt.ngayvv <= $2 AND (nt.ravien = 0 OR nt.ngayrv >= $1) THEN 1 ELSE 0 END) > 0 ORDER BY benh_moi DESC; `; const resKhoa = await db.query(sqlKhoa, params, database); return { khoa: resKhoa.rows, }; } // 3. API Báo cáo Giao ban Nội trú router.get('/api/giao-ban-noi-tru', async (req, res) => { try { const { database, from, to, compareFrom, compareTo } = req.query; const dbName = database || process.env.PG_DATABASE; const fromDate = from || '2026-03-01 00:00:00'; const toDate = to || '2026-03-31 23:59:59'; await ensureTable(dbName); const currentData = await getInpatientData(dbName, fromDate, toDate); let compareData = null; if (compareFrom && compareTo) { compareData = await getInpatientData(dbName, compareFrom, compareTo); } res.json({ success: true, data: { current: currentData, compare: compareData, periodCurrent: { from: fromDate, to: toDate }, periodCompare: compareFrom && compareTo ? { from: compareFrom, to: compareTo } : null, }, }); } catch (error) { console.error('Error in /api/giao-ban-noi-tru:', error); res.status(500).json({ success: false, message: error.message }); } }); // 4. API Bảng 3: Danh sách Bệnh nhân Chuyển viện router.get('/api/danh-sach-chuyen-vien', async (req, res) => { try { const { database, from, to, madv } = req.query; const dbName = database || process.env.PG_DATABASE; const fromDate = from || '2026-03-01 00:00:00'; const toDate = to || '2026-03-31 23:59:59'; await ensureTable(dbName); let sqlWhere = ` WHERE nt.namvien = 1 AND COALESCE(nt.xoa, 0) = 0 AND nt.ngayrv BETWEEN $1 AND $2 AND (nt.ravien = 4 OR nt.maxt = '03') `; const params = [fromDate, toDate]; if (madv && madv !== 'ALL') { params.push(madv); sqlWhere += ` AND nt.madv = $${params.length} `; } const sql = ` SELECT nt.mabn, nt.maba, bn.holot || ' ' || bn.ten AS hoten, bn.diachi, to_char(nt.ngayvv, 'DD/MM/YYYY HH24:MI') AS ngayvv, to_char(nt.ngayrv, 'DD/MM/YYYY HH24:MI') AS ngayrv, COALESCE(dv.tendv, nt.madv) AS tendv, nt.kqcdoan AS chandoan_chinh, nt.kqcdoanp AS chandoan_phu, COALESCE(cls_sub.ketqua_cls, 'Chưa có kết quả') AS ketqua_cls, COALESCE(gb.ghichu, nt.ghichu, '') AS ghichu FROM current.bnnoitru nt JOIN current.dmbenhnhan bn ON nt.mabn = bn.mabn LEFT JOIN current.dmdonvi dv ON nt.madv = dv.madv LEFT JOIN current.giaoban_ghichu gb ON nt.maba = gb.maba AND gb.loai_danhsach = 'CHUYEN_VIEN' LEFT JOIN LATERAL ( SELECT string_agg(DISTINCT cls.tencls || COALESCE(': ' || NULLIF(ha.ketluan,''), ''), '; ') AS ketqua_cls FROM current.chidinhcls ls JOIN current.dmcls cls ON ls.macls = cls.macls LEFT JOIN current.pskhamha ha ON ls.mabn = ha.mabn AND ls.macls = ha.macls AND ls.makb = ha.makb WHERE ls.mabn = nt.mabn AND ls.makb = nt.makb AND COALESCE(ls.xoa, 0) = 0 ) cls_sub ON true ${sqlWhere} ORDER BY nt.ngayrv DESC LIMIT 100; `; const result = await db.query(sql, params, dbName); const cleanedRows = result.rows.map(row => ({ ...row, ketqua_cls: db.cleanRtf(row.ketqua_cls) })); res.json({ success: true, data: cleanedRows }); } catch (error) { console.error('Error in /api/danh-sach-chuyen-vien:', error); res.status(500).json({ success: false, message: error.message }); } }); // 5. API Bảng 4: Danh sách Bệnh nhân Theo dõi & Chăm sóc Cấp I router.get('/api/danh-sach-theo-doi', async (req, res) => { try { const { database, from, to, madv } = req.query; const dbName = database || process.env.PG_DATABASE; const fromDate = from || '2026-03-01 00:00:00'; const toDate = to || '2026-03-31 23:59:59'; await ensureTable(dbName); let sqlWhere = ` WHERE nt.namvien = 1 AND COALESCE(nt.xoa, 0) = 0 AND (nt.ngayvv <= $2 AND (nt.ravien = 0 OR nt.ngayrv >= $1)) AND ( COALESCE(nt.benhnang, 0) = 1 OR COALESCE(nt.mabatheodoi, '') != '' OR nt.chamsoc ILIKE '%cấp I%' OR nt.chamsoc ILIKE '%cấp 1%' OR nt.chamsoc IN ('1', 'CS I', 'CSI') OR nt.madv IN ('17', '29') ) `; const params = [fromDate, toDate]; if (madv && madv !== 'ALL') { params.push(madv); sqlWhere += ` AND nt.madv = $${params.length} `; } const sql = ` SELECT nt.mabn, nt.maba, bn.holot || ' ' || bn.ten AS hoten, bn.diachi, to_char(nt.ngayvv, 'DD/MM/YYYY HH24:MI') AS ngayvv, to_char(nt.ngayrv, 'DD/MM/YYYY HH24:MI') AS ngayrv, COALESCE(dv.tendv, nt.madv) AS tendv, nt.kqcdoan AS chandoan_chinh, nt.kqcdoanp AS chandoan_phu, COALESCE(nt.chamsoc, 'Theo dõi đặc biệt') AS chamsoc, COALESCE(cls_sub.ketqua_cls, 'Chưa có kết quả') AS ketqua_cls, COALESCE(gb.ghichu, nt.ghichu, '') AS ghichu FROM current.bnnoitru nt JOIN current.dmbenhnhan bn ON nt.mabn = bn.mabn LEFT JOIN current.dmdonvi dv ON nt.madv = dv.madv LEFT JOIN current.giaoban_ghichu gb ON nt.maba = gb.maba AND gb.loai_danhsach = 'THEO_DOI' LEFT JOIN LATERAL ( SELECT string_agg(DISTINCT cls.tencls || COALESCE(': ' || NULLIF(ha.ketluan,''), ''), '; ') AS ketqua_cls FROM current.chidinhcls ls JOIN current.dmcls cls ON ls.macls = cls.macls LEFT JOIN current.pskhamha ha ON ls.mabn = ha.mabn AND ls.macls = ha.macls AND ls.makb = ha.makb WHERE ls.mabn = nt.mabn AND ls.makb = nt.makb AND COALESCE(ls.xoa, 0) = 0 ) cls_sub ON true ${sqlWhere} ORDER BY nt.ngayvv DESC LIMIT 100; `; const result = await db.query(sql, params, dbName); const cleanedRows = result.rows.map(row => ({ ...row, ketqua_cls: db.cleanRtf(row.ketqua_cls) })); res.json({ success: true, data: cleanedRows }); } catch (error) { console.error('Error in /api/danh-sach-theo-doi:', error); res.status(500).json({ success: false, message: error.message }); } }); // 6. API Cập nhật Ghi chú trực tiếp từ lưới (Bảng 3 & Bảng 4) router.post('/api/cap-nhat-ghi-chu', async (req, res) => { try { const { maba, mabn, loai, ghichu, database } = req.body; if (!maba || !loai) { return res.status(400).json({ success: false, message: 'Thiếu maba hoặc loại danh sách' }); } const dbName = database || process.env.PG_DATABASE; await ensureTable(dbName); const sqlUpsert = ` INSERT INTO current.giaoban_ghichu (maba, mabn, loai_danhsach, ghichu, ngay_capnhat) VALUES ($1, $2, $3, $4, NOW()) ON CONFLICT (maba, loai_danhsach) DO UPDATE SET ghichu = EXCLUDED.ghichu, ngay_capnhat = NOW(); `; await db.query(sqlUpsert, [maba, mabn || '', loai, ghichu || ''], dbName); // Đồng bộ vào bnnoitru.ghichu nếu cần try { await db.query(`UPDATE current.bnnoitru SET ghichu = $1 WHERE maba = $2;`, [ghichu || '', maba], dbName); } catch (e) { // ignore } res.json({ success: true, message: 'Đã lưu ghi chú thành công!' }); } catch (error) { console.error('Error in /api/cap-nhat-ghi-chu:', error); res.status(500).json({ success: false, message: error.message }); } }); // 7. API Quản lý Issue & Báo lỗi (lưu file và tổng hợp) router.get('/api/issues', (req, res) => { try { const list = issueService.getAllIssues(); res.json({ success: true, data: list }); } catch (error) { res.status(500).json({ success: false, message: error.message }); } }); router.post('/api/issues', upload.single('attachment'), (req, res) => { try { const issue = issueService.createIssue(req.body, req.file); res.json({ success: true, data: issue, message: 'Đã ghi nhận báo lỗi thành công!' }); } catch (error) { console.error('Error creating issue:', error); res.status(500).json({ success: false, message: error.message }); } }); router.put('/api/issues/:id', (req, res) => { try { const updated = issueService.updateIssue(req.params.id, req.body); res.json({ success: true, data: updated, message: 'Cập nhật trạng thái issue thành công!' }); } catch (error) { console.error('Error updating issue:', error); res.status(500).json({ success: false, message: error.message }); } }); router.delete('/api/issues/:id', (req, res) => { try { issueService.deleteIssue(req.params.id); res.json({ success: true, message: 'Đã xóa issue thành công!' }); } catch (error) { console.error('Error deleting issue:', error); res.status(500).json({ success: false, message: error.message }); } }); // Mount router on both /web-giao-ban and / app.use('/web-giao-ban', router); app.use('/', router); app.listen(PORT, '0.0.0.0', () => { console.log(`=======================================================`); console.log(`[DH-HIS] Phần mềm Quản lý Giao ban hằng ngày (Node.js)`); console.log(`Server is running at: http://localhost:${PORT}`); console.log(`=======================================================`); });