feat: DH-HIS Web Giao Ban Go v1.0.1 (Favicon, Exe icon, System Tray, Gitea auto-update)

This commit is contained in:
ongtrieuhau
2026-10-26 00:10:07 -07:00
commit 228d4d14df
32 changed files with 5914 additions and 0 deletions
+218
View File
@@ -0,0 +1,218 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"web-giao-ban-go/config"
"web-giao-ban-go/db"
"web-giao-ban-go/logger"
"web-giao-ban-go/updater"
)
// GET /api/admin/config
func HandleGetAdminConfig(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
cfg := config.Get()
// Mask sensitive fields
respCfg := *cfg
if respCfg.Server.AdminPassword != "" {
respCfg.Server.AdminPassword = "••••••••"
}
if respCfg.Database.Password != "" {
respCfg.Database.Password = "••••••••"
}
if respCfg.Updater.Token != "" {
respCfg.Updater.Token = "••••••••"
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"data": respCfg,
})
}
// POST /api/admin/config
func HandleSaveAdminConfig(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
var input config.AppConfig
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": "JSON không hợp lệ: " + err.Error()})
return
}
current := config.Get()
// Handle Admin Password
if input.Server.AdminPassword == "" || input.Server.AdminPassword == "••••••••" {
input.Server.AdminPassword = current.Server.AdminPassword
input.DecryptedAdminPassword = current.DecryptedAdminPassword
} else {
input.DecryptedAdminPassword = input.Server.AdminPassword
enc, _ := config.Encrypt(input.Server.AdminPassword)
input.Server.AdminPassword = enc
}
// Handle DB Password
if input.Database.Password == "" || input.Database.Password == "••••••••" {
input.Database.Password = current.Database.Password
input.DecryptedDBPassword = current.DecryptedDBPassword
} else {
input.DecryptedDBPassword = input.Database.Password
enc, _ := config.Encrypt(input.Database.Password)
input.Database.Password = enc
}
// Handle Updater Token
if input.Updater.Token == "" || input.Updater.Token == "••••••••" {
input.Updater.Token = current.Updater.Token
input.DecryptedUpdaterToken = current.DecryptedUpdaterToken
} else {
input.DecryptedUpdaterToken = input.Updater.Token
enc, _ := config.Encrypt(input.Updater.Token)
input.Updater.Token = enc
}
if err := config.SaveConfig(&input); err != nil {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": "Lưu file thất bại: " + err.Error()})
return
}
// Reset DB pool to apply new connection parameters
db.CloseAll()
logger.Info("Đã lưu cấu hình mới và làm mới kết nối CSDL")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"message": "Đã lưu cấu hình và mã hóa mật khẩu thành công!",
})
}
// POST /api/admin/test-db
func HandleTestDBConnection(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
var body struct {
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
Database string `json:"database"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": "Dữ liệu không hợp lệ"})
return
}
cfg := config.Get()
pass := body.Password
if pass == "" || pass == "••••••••" {
pass = cfg.DecryptedDBPassword
}
err := db.TestConnection(body.Host, body.Port, body.User, pass, body.Database)
if err != nil {
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": "Kết nối thất bại: " + err.Error(),
})
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"message": "Kết nối PostgreSQL thành công!",
})
}
// GET /api/admin/logs
func HandleGetLogs(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
limitStr := r.URL.Query().Get("limit")
level := r.URL.Query().Get("level")
limit := 100
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
limit = l
}
entries := logger.Get().GetEntries(limit, strings.ToUpper(level))
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"data": entries,
})
}
// POST /api/admin/logs/clear
func HandleClearLogs(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
logger.Get().Clear()
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"message": "Đã làm trống bộ nhớ log!",
})
}
// GET /api/update/check
func HandleCheckUpdate(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
res, err := updater.CheckUpdate()
if err != nil {
logger.Error("Kiểm tra cập nhật lỗi: %v", err)
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": err.Error(),
})
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"data": res,
})
}
// POST /api/update/apply
func HandleApplyUpdate(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
var body struct {
AssetURL string `json:"asset_url"`
Version string `json:"version"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.AssetURL == "" {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": "Thiếu thông tin URL bản cập nhật"})
return
}
err := updater.ApplyUpdate(body.AssetURL)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": "Cập nhật thất bại: " + err.Error(),
})
return
}
// Update current version in config if provided
if body.Version != "" {
cfg := config.Get()
cfg.Updater.CurrentVersion = body.Version
_ = config.SaveConfig(cfg)
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"message": "Đã cập nhật phiên bản mới thành công! Vui lòng khởi động lại phần mềm để áp dụng.",
})
}
+681
View File
@@ -0,0 +1,681 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"web-giao-ban-go/config"
"web-giao-ban-go/db"
)
// Helper to generate partition filters: namkt IN (...) AND thangkt IN (...)
func GetThangNamFilter(fromStr, toStr, prefix string) string {
f, err1 := time.Parse("2006-01-02 15:04:05", fromStr)
t, err2 := time.Parse("2006-01-02 15:04:05", toStr)
if err1 != nil || err2 != nil {
// Try date only
f, err1 = time.Parse("2006-01-02", fromStr[:10])
t, err2 = time.Parse("2006-01-02", toStr[:10])
if err1 != nil || err2 != nil {
return ""
}
}
thangsMap := make(map[string]bool)
namsMap := make(map[string]bool)
curr := time.Date(f.Year(), f.Month(), 1, 0, 0, 0, 0, time.UTC)
end := time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.UTC)
count := 0
for !curr.After(end) && count < 24 {
thangsMap[fmt.Sprintf("'%02d'", curr.Month())] = true
namsMap[fmt.Sprintf("'%d'", curr.Year())] = true
curr = curr.AddDate(0, 1, 0)
count++
}
var thangsArr, namsArr []string
for k := range thangsMap {
thangsArr = append(thangsArr, k)
}
for k := range namsMap {
namsArr = append(namsArr, k)
}
if len(thangsArr) == 0 || len(namsArr) == 0 {
return ""
}
p := ""
if prefix != "" {
p = prefix + "."
}
return fmt.Sprintf(" AND %snamkt IN (%s) AND %sthangkt IN (%s) ",
p, strings.Join(namsArr, ","),
p, strings.Join(thangsArr, ","),
)
}
// GET /api/databases
func HandleGetDatabases(w http.ResponseWriter, r *http.Request) {
cfg := config.Get()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"data": cfg.Database.Databases,
})
}
type OutpatientData struct {
Khoa []map[string]interface{} `json:"khoa"`
BANT map[string]interface{} `json:"bant"`
YDCT map[string]interface{} `json:"ydct"`
CLS []map[string]interface{} `json:"cls"`
Top10 []map[string]interface{} `json:"top10"`
TiemNgua int `json:"tiemngua"`
}
func queryOutpatient(database, fromDate, toDate string) (*OutpatientData, error) {
conn, err := db.GetDB(database)
if err != nil {
return nil, err
}
fnKb := GetThangNamFilter(fromDate, toDate, "kb")
fnLs := GetThangNamFilter(fromDate, toDate, "ls")
fnNone := GetThangNamFilter(fromDate, toDate, "")
out := &OutpatientData{
Khoa: make([]map[string]interface{}, 0),
BANT: map[string]interface{}{"moi": 0, "taikham": 0, "tong": 0},
YDCT: map[string]interface{}{"dien_cham": 0, "vltl": 0, "sac_thuoc": 0, "cay_chi": 0},
CLS: make([]map[string]interface{}, 0),
Top10: make([]map[string]interface{}, 0),
}
var wg sync.WaitGroup
var mu sync.Mutex
// 1. Khoa KCB
wg.Add(1)
go func() {
defer wg.Done()
sqlKhoa := fmt.Sprintf(`
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 %s
AND COALESCE(kb.xoa, 0) = 0
AND COALESCE(kb.dakham, 0) != 0
GROUP BY dv.madv, dv.tendv
ORDER BY tong_kham DESC;
`, fnKb)
rows, err := conn.Query(sqlKhoa, fromDate, toDate)
if err == nil {
defer rows.Close()
var list []map[string]interface{}
for rows.Next() {
var madv, tendv string
var tong, bhyt, thuphi, cv int
_ = rows.Scan(&madv, &tendv, &tong, &bhyt, &thuphi, &cv)
list = append(list, map[string]interface{}{
"madv": madv, "tendv": tendv, "tong_kham": tong,
"bhyt": bhyt, "thuphi": thuphi, "chuyenvien": cv,
})
}
mu.Lock()
out.Khoa = list
mu.Unlock()
}
}()
// 2. BANT
wg.Add(1)
go func() {
defer wg.Done()
sqlBant := `
SELECT
COALESCE(SUM(CASE WHEN COALESCE(dk.taikham, 0) = 0 THEN 1 ELSE 0 END), 0) AS moi,
COALESCE(SUM(CASE WHEN COALESCE(dk.taikham, 0) = 1 THEN 1 ELSE 0 END), 0) 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;
`
var moi, taikham, tong int
err := conn.QueryRow(sqlBant, fromDate, toDate).Scan(&moi, &taikham, &tong)
if err == nil {
mu.Lock()
out.BANT = map[string]interface{}{"moi": moi, "taikham": taikham, "tong": tong}
mu.Unlock()
}
}()
// 3. YDCT
wg.Add(1)
go func() {
defer wg.Done()
sqlYdct := fmt.Sprintf(`
SELECT
COALESCE(SUM(CASE WHEN cls.tencls ILIKE '%%Điện châm%%' THEN ls.soluong ELSE 0 END), 0) AS dien_cham,
COALESCE(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), 0) AS vltl,
COALESCE(SUM(CASE WHEN cls.tencls ILIKE '%%Sắc thuốc%%' THEN ls.soluong ELSE 0 END), 0) AS sac_thuoc,
COALESCE(SUM(CASE WHEN cls.tencls ILIKE '%%Cấy chỉ%%' OR cls.tencls ILIKE '%%Chôn chỉ%%' THEN ls.soluong ELSE 0 END), 0) AS cay_chi
FROM current.chidinhcls ls
JOIN current.dmcls cls ON ls.macls = cls.macls
WHERE ls.ngaykcb BETWEEN $1 AND $2 %s
AND COALESCE(ls.xoa, 0) = 0;
`, fnLs)
var dc, vltl, st, cc float64
err := conn.QueryRow(sqlYdct, fromDate, toDate).Scan(&dc, &vltl, &st, &cc)
if err == nil {
mu.Lock()
out.YDCT = map[string]interface{}{
"dien_cham": int(dc), "vltl": int(vltl), "sac_thuoc": int(st), "cay_chi": int(cc),
}
mu.Unlock()
}
}()
// 4. CLS
wg.Add(1)
go func() {
defer wg.Done()
sqlCls := fmt.Sprintf(`
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 %s
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;
`, fnLs)
rows, err := conn.Query(sqlCls, fromDate, toDate)
if err == nil {
defer rows.Close()
var list []map[string]interface{}
for rows.Next() {
var kho, maloai, tenloai string
var soBn, soLuot int
_ = rows.Scan(&kho, &maloai, &tenloai, &soBn, &soLuot)
list = append(list, map[string]interface{}{
"kho": kho, "maloai": maloai, "tenloai": tenloai, "so_bn": soBn, "so_luot": soLuot,
})
}
mu.Lock()
out.CLS = list
mu.Unlock()
}
}()
// 5. Top 10
wg.Add(1)
go func() {
defer wg.Done()
sqlTop10 := fmt.Sprintf(`
SELECT maicd, kqcdoan, COUNT(*) AS sl
FROM current.khambenh
WHERE ngaykcb BETWEEN $1 AND $2 %s
AND COALESCE(xoa, 0) = 0
AND maicd IS NOT NULL AND maicd != ''
GROUP BY maicd, kqcdoan
ORDER BY sl DESC
LIMIT 10;
`, fnNone)
rows, err := conn.Query(sqlTop10, fromDate, toDate)
if err == nil {
defer rows.Close()
var list []map[string]interface{}
for rows.Next() {
var maicd, kqcdoan string
var sl int
_ = rows.Scan(&maicd, &kqcdoan, &sl)
list = append(list, map[string]interface{}{
"maicd": maicd, "kqcdoan": kqcdoan, "sl": sl,
})
}
mu.Lock()
out.Top10 = list
mu.Unlock()
}
}()
// 6. Tiêm ngừa
wg.Add(1)
go func() {
defer wg.Done()
sqlTn := fmt.Sprintf(`
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 %s
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%%'
);
`, fnKb)
var sl int
err := conn.QueryRow(sqlTn, fromDate, toDate).Scan(&sl)
if err == nil {
mu.Lock()
out.TiemNgua = sl
mu.Unlock()
}
}()
wg.Wait()
return out, nil
}
// GET /api/giao-ban-ngoai-tru
func HandleGiaoBanNgoaiTru(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
q := r.URL.Query()
database := q.Get("database")
from := q.Get("from")
to := q.Get("to")
compareFrom := q.Get("compareFrom")
compareTo := q.Get("compareTo")
if from == "" {
from = "2026-03-01 00:00:00"
}
if to == "" {
to = "2026-03-31 23:59:59"
}
curData, err := queryOutpatient(database, from, to)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": err.Error()})
return
}
var compData *OutpatientData
if compareFrom != "" && compareTo != "" {
compData, _ = queryOutpatient(database, compareFrom, compareTo)
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"data": map[string]interface{}{
"current": curData,
"compare": compData,
"periodCurrent": map[string]string{
"from": from, "to": to,
},
"periodCompare": func() interface{} {
if compareFrom != "" && compareTo != "" {
return map[string]string{"from": compareFrom, "to": compareTo}
}
return nil
}(),
},
})
}
type InpatientData struct {
Khoa []map[string]interface{} `json:"khoa"`
}
func queryInpatient(database, fromDate, toDate string) (*InpatientData, error) {
conn, err := db.GetDB(database)
if err != nil {
return nil, err
}
sqlKhoa := `
SELECT
dv.madv,
dv.tendv,
SUM(CASE WHEN nt.ngayvv < $1 AND (nt.ravien = 0 OR nt.ngayrv >= $1) THEN 1 ELSE 0 END) AS benh_cu,
SUM(CASE WHEN nt.ngayvv BETWEEN $1 AND $2 THEN 1 ELSE 0 END) AS benh_moi,
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,
SUM(CASE WHEN nt.ngayvv <= $2 AND (nt.ravien = 0 OR nt.ngayrv > $2) THEN 1 ELSE 0 END) AS benh_hien_con,
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,
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;
`
rows, err := conn.Query(sqlKhoa, fromDate, toDate)
if err != nil {
return nil, err
}
defer rows.Close()
list := make([]map[string]interface{}, 0)
for rows.Next() {
var madv, tendv string
var cu, moi, on, nang, tron, cv, tv, hiencon, td, cs1 int
_ = rows.Scan(&madv, &tendv, &cu, &moi, &on, &nang, &tron, &cv, &tv, &hiencon, &td, &cs1)
list = append(list, map[string]interface{}{
"madv": madv, "tendv": tendv,
"benh_cu": cu, "benh_moi": moi, "on_xin_ve": on, "nang_xin_ve": nang,
"tron_vien": tron, "chuyen_vien": cv, "tu_vong": tv, "benh_hien_con": hiencon,
"benh_theo_doi": td, "chamsoc_cap1": cs1,
})
}
return &InpatientData{Khoa: list}, nil
}
// GET /api/giao-ban-noi-tru
func HandleGiaoBanNoiTru(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
q := r.URL.Query()
database := q.Get("database")
from := q.Get("from")
to := q.Get("to")
compareFrom := q.Get("compareFrom")
compareTo := q.Get("compareTo")
if from == "" {
from = "2026-03-01 00:00:00"
}
if to == "" {
to = "2026-03-31 23:59:59"
}
db.EnsureTable(database)
curData, err := queryInpatient(database, from, to)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": err.Error()})
return
}
var compData *InpatientData
if compareFrom != "" && compareTo != "" {
compData, _ = queryInpatient(database, compareFrom, compareTo)
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"data": map[string]interface{}{
"current": curData,
"compare": compData,
"periodCurrent": map[string]string{
"from": from, "to": to,
},
"periodCompare": func() interface{} {
if compareFrom != "" && compareTo != "" {
return map[string]string{"from": compareFrom, "to": compareTo}
}
return nil
}(),
},
})
}
// GET /api/danh-sach-chuyen-vien
func HandleDanhSachChuyenVien(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
q := r.URL.Query()
database := q.Get("database")
from := q.Get("from")
to := q.Get("to")
madv := q.Get("madv")
if from == "" {
from = "2026-03-01 00:00:00"
}
if to == "" {
to = "2026-03-31 23:59:59"
}
conn, err := db.GetDB(database)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": err.Error()})
return
}
db.EnsureTable(database)
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')
`
args := []interface{}{from, to}
if madv != "" && madv != "ALL" {
args = append(args, madv)
sqlWhere += fmt.Sprintf(" AND nt.madv = $%d ", len(args))
}
sqlStr := fmt.Sprintf(`
SELECT
nt.mabn,
nt.maba,
bn.holot || ' ' || bn.ten AS hoten,
COALESCE(bn.diachi, '') AS 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,
COALESCE(nt.kqcdoan, '') AS chandoan_chinh,
COALESCE(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
%s
ORDER BY nt.ngayrv DESC
LIMIT 100;
`, sqlWhere)
rows, err := conn.Query(sqlStr, args...)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": err.Error()})
return
}
defer rows.Close()
list := make([]map[string]interface{}, 0)
for rows.Next() {
var mabn, maba, hoten, diachi, ngayvv, ngayrv, tendv, cdChinh, cdPhu, cls, ghichu string
_ = rows.Scan(&mabn, &maba, &hoten, &diachi, &ngayvv, &ngayrv, &tendv, &cdChinh, &cdPhu, &cls, &ghichu)
list = append(list, map[string]interface{}{
"mabn": mabn, "maba": maba, "hoten": hoten, "diachi": diachi,
"ngayvv": ngayvv, "ngayrv": ngayrv, "tendv": tendv,
"chandoan_chinh": cdChinh, "chandoan_phu": cdPhu,
"ketqua_cls": db.CleanRTF(cls), "ghichu": ghichu,
})
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"data": list,
})
}
// GET /api/danh-sach-theo-doi
func HandleDanhSachTheoDoi(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
q := r.URL.Query()
database := q.Get("database")
from := q.Get("from")
to := q.Get("to")
madv := q.Get("madv")
if from == "" {
from = "2026-03-01 00:00:00"
}
if to == "" {
to = "2026-03-31 23:59:59"
}
conn, err := db.GetDB(database)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": err.Error()})
return
}
db.EnsureTable(database)
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')
)
`
args := []interface{}{from, to}
if madv != "" && madv != "ALL" {
args = append(args, madv)
sqlWhere += fmt.Sprintf(" AND nt.madv = $%d ", len(args))
}
sqlStr := fmt.Sprintf(`
SELECT
nt.mabn,
nt.maba,
bn.holot || ' ' || bn.ten AS hoten,
COALESCE(bn.diachi, '') AS 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,
COALESCE(nt.kqcdoan, '') AS chandoan_chinh,
COALESCE(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
%s
ORDER BY nt.ngayvv DESC
LIMIT 100;
`, sqlWhere)
rows, err := conn.Query(sqlStr, args...)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": err.Error()})
return
}
defer rows.Close()
list := make([]map[string]interface{}, 0)
for rows.Next() {
var mabn, maba, hoten, diachi, ngayvv, ngayrv, tendv, cdChinh, cdPhu, chamsoc, cls, ghichu string
_ = rows.Scan(&mabn, &maba, &hoten, &diachi, &ngayvv, &ngayrv, &tendv, &cdChinh, &cdPhu, &chamsoc, &cls, &ghichu)
list = append(list, map[string]interface{}{
"mabn": mabn, "maba": maba, "hoten": hoten, "diachi": diachi,
"ngayvv": ngayvv, "ngayrv": ngayrv, "tendv": tendv,
"chandoan_chinh": cdChinh, "chandoan_phu": cdPhu,
"chamsoc": chamsoc, "ketqua_cls": db.CleanRTF(cls), "ghichu": ghichu,
})
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"data": list,
})
}
// POST /api/cap-nhat-ghi-chu
func HandleCapNhatGhiChu(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
var body struct {
Maba string `json:"maba"`
Mabn string `json:"mabn"`
Loai string `json:"loai"`
Ghichu string `json:"ghichu"`
Database string `json:"database"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Maba == "" || body.Loai == "" {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": "Thiếu thông tin maba hoặc loai danh sách"})
return
}
conn, err := db.GetDB(body.Database)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": err.Error()})
return
}
db.EnsureTable(body.Database)
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();
`
_, err = conn.Exec(sqlUpsert, body.Maba, body.Mabn, body.Loai, body.Ghichu)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": err.Error()})
return
}
// Try sync to bnnoitru.ghichu
_, _ = conn.Exec("UPDATE current.bnnoitru SET ghichu = $1 WHERE maba = $2;", body.Ghichu, body.Maba)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"message": "Đã lưu ghi chú thành công!",
})
}
+287
View File
@@ -0,0 +1,287 @@
package handlers
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"web-giao-ban-go/logger"
)
type AttachmentInfo struct {
Filename string `json:"filename"`
OriginalName string `json:"originalname"`
Size int64 `json:"size"`
MimeType string `json:"mimetype"`
URL string `json:"url"`
}
type Issue struct {
ID string `json:"id"`
Title string `json:"title"`
Category string `json:"category"`
Module string `json:"module"`
Priority string `json:"priority"`
Database string `json:"database"`
DatabaseName string `json:"databaseName"`
Period string `json:"period"`
Reporter string `json:"reporter"`
Department string `json:"department"`
Description string `json:"description"`
Status string `json:"status"` // CHO_XU_LY, DANG_XU_LY, DA_XU_LY, DONG
HandlerNotes string `json:"handlerNotes"`
ResolvedBy string `json:"resolvedBy"`
ResolvedAt *string `json:"resolvedAt"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Attachment *AttachmentInfo `json:"attachment,omitempty"`
}
var (
issuesMu sync.RWMutex
dataFile = "data/issues.json"
uploadsDir = "uploads/issues"
)
func EnsureDirs() {
_ = os.MkdirAll("data", 0755)
_ = os.MkdirAll(uploadsDir, 0755)
if _, err := os.Stat(dataFile); os.IsNotExist(err) {
_ = os.WriteFile(dataFile, []byte("[]"), 0644)
}
}
func getAllIssues() []Issue {
EnsureDirs()
issuesMu.RLock()
defer issuesMu.RUnlock()
raw, err := os.ReadFile(dataFile)
if err != nil {
return []Issue{}
}
var list []Issue
if err := json.Unmarshal(raw, &list); err != nil {
return []Issue{}
}
return list
}
func saveIssues(list []Issue) error {
EnsureDirs()
issuesMu.Lock()
defer issuesMu.Unlock()
data, err := json.MarshalIndent(list, "", " ")
if err != nil {
return err
}
tmp := dataFile + ".tmp"
if err := os.WriteFile(tmp, data, 0644); err != nil {
return err
}
return os.Rename(tmp, dataFile)
}
func generateIssueID(all []Issue) string {
now := time.Now()
prefix := fmt.Sprintf("ISS-%s", now.Format("20060102"))
count := 1
for _, item := range all {
if strings.HasPrefix(item.ID, prefix) {
count++
}
}
return fmt.Sprintf("%s-%03d", prefix, count)
}
// GET /api/issues
func HandleGetIssues(w http.ResponseWriter, r *http.Request) {
list := getAllIssues()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"data": list,
})
}
// POST /api/issues
func HandleCreateIssue(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
// Limit 25MB
if err := r.ParseMultipartForm(25 << 20); err != nil {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": "File quá lớn (tối đa 25MB)"})
return
}
title := strings.TrimSpace(r.FormValue("title"))
if title == "" {
title = "Sự cố không có tiêu đề"
}
all := getAllIssues()
id := generateIssueID(all)
nowStr := time.Now().Format(time.RFC3339)
var attach *AttachmentInfo
file, header, err := r.FormFile("attachment")
if err == nil && file != nil {
defer file.Close()
ext := filepath.Ext(header.Filename)
cleanBase := strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
return r
}
return '_'
}, strings.TrimSuffix(header.Filename, ext))
safeFilename := fmt.Sprintf("%s_%d%s", cleanBase, time.Now().UnixNano()/1e6, ext)
destPath := filepath.Join(uploadsDir, safeFilename)
dst, err := os.Create(destPath)
if err == nil {
defer dst.Close()
_, _ = io.Copy(dst, file)
attach = &AttachmentInfo{
Filename: safeFilename,
OriginalName: header.Filename,
Size: header.Size,
MimeType: header.Header.Get("Content-Type"),
URL: "/uploads/issues/" + safeFilename,
}
}
}
newIssue := Issue{
ID: id,
Title: title,
Category: r.FormValue("category"),
Module: r.FormValue("module"),
Priority: r.FormValue("priority"),
Database: r.FormValue("database"),
DatabaseName: r.FormValue("databaseName"),
Period: r.FormValue("period"),
Reporter: strings.TrimSpace(r.FormValue("reporter")),
Department: strings.TrimSpace(r.FormValue("department")),
Description: strings.TrimSpace(r.FormValue("description")),
Status: "CHO_XU_LY",
CreatedAt: nowStr,
UpdatedAt: nowStr,
Attachment: attach,
}
all = append([]Issue{newIssue}, all...)
_ = saveIssues(all)
logger.Info("Đã ghi nhận Issue mới: [%s] %s từ %s", id, title, newIssue.Reporter)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"data": newIssue,
"message": "Đã ghi nhận báo lỗi thành công!",
})
}
// PUT /api/issues/{id}
func HandleUpdateIssue(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
pathParts := strings.Split(r.URL.Path, "/")
id := pathParts[len(pathParts)-1]
var body struct {
Status string `json:"status"`
ResolvedBy string `json:"resolvedBy"`
HandlerNotes string `json:"handlerNotes"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": "Dữ liệu không hợp lệ"})
return
}
all := getAllIssues()
found := false
var updated Issue
nowStr := time.Now().Format(time.RFC3339)
for i := range all {
if all[i].ID == id {
if body.Status != "" {
all[i].Status = body.Status
}
all[i].ResolvedBy = body.ResolvedBy
all[i].HandlerNotes = body.HandlerNotes
all[i].UpdatedAt = nowStr
if (body.Status == "DA_XU_LY" || body.Status == "DONG") && all[i].ResolvedAt == nil {
all[i].ResolvedAt = &nowStr
}
updated = all[i]
found = true
break
}
}
if !found {
w.WriteHeader(http.StatusNotFound)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": "Không tìm thấy issue"})
return
}
_ = saveIssues(all)
logger.Info("Cập nhật Issue [%s]: Trạng thái=%s, Kỹ thuật=%s", id, updated.Status, updated.ResolvedBy)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"data": updated,
"message": "Cập nhật issue thành công!",
})
}
// DELETE /api/issues/{id}
func HandleDeleteIssue(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
pathParts := strings.Split(r.URL.Path, "/")
id := pathParts[len(pathParts)-1]
all := getAllIssues()
filtered := make([]Issue, 0, len(all))
var removed *Issue
for _, item := range all {
if item.ID == id {
cpy := item
removed = &cpy
continue
}
filtered = append(filtered, item)
}
if removed == nil {
w.WriteHeader(http.StatusNotFound)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": "Không tìm thấy issue"})
return
}
if removed.Attachment != nil && removed.Attachment.Filename != "" {
_ = os.Remove(filepath.Join(uploadsDir, removed.Attachment.Filename))
}
_ = saveIssues(filtered)
logger.Info("Đã xóa Issue [%s]", id)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"message": "Đã xóa issue thành công!",
})
}