package handlers import ( "context" "encoding/json" "fmt" "strconv" "time" internalDb "server/internal/db" "server/internal/models" "server/internal/qldt" "github.com/gofiber/fiber/v2" "gorm.io/gorm" "gorm.io/gorm/clause" ) var standardShiftTemplate = []struct { Period int StartTime string EndTime string }{ {1, "07:00", "09:00"}, {2, "09:10", "11:10"}, {3, "12:10", "14:10"}, {4, "14:20", "16:20"}, } // GET /api/classes/:rkId/courses func GetClassCoursesHandler(db *gorm.DB, qldtClient *qldt.Client) fiber.Handler { return func(c *fiber.Ctx) error { rkID, err := strconv.ParseInt(c.Params("rkId"), 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class rkId"}) } token := GetQldtToken(db) var liveErr error if token != "" { res, err := qldtClient.GetClassCourses(context.Background(), token, rkID) if err == nil && len(res.Data) > 0 { dtos := qldtCourseItemsToDTO(res.Data) _ = internalDb.UpsertClassCourses(db, rkID, dtos) return c.JSON(fiber.Map{"data": dtos, "source": "qldt"}) } liveErr = err } else { liveErr = fmt.Errorf("QLDT_TOKEN chưa cấu hình") } cached, err := internalDb.ListClassCourses(db, rkID) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } if len(cached) > 0 { resp := fiber.Map{ "data": cached, "source": "cache", "cached": true, } if liveErr != nil { resp["warning"] = liveErr.Error() } return c.JSON(resp) } if liveErr != nil { return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{ "error": liveErr.Error(), "hint": "Cập nhật QLDT_TOKEN trong server/.env rồi chạy Sync lớp học để tải danh sách môn.", }) } return c.JSON(fiber.Map{"data": []internalDb.ClassCourseDTO{}}) } } func qldtCourseItemsToDTO(items []qldt.CourseItem) []internalDb.ClassCourseDTO { out := make([]internalDb.ClassCourseDTO, 0, len(items)) for _, it := range items { if it.ID <= 0 { continue } out = append(out, internalDb.ClassCourseDTO{ ID: it.ID, Name: it.Name, CourseCode: it.CourseCode, }) } return out } // POST /api/classes/:rkId/schedule/apply-template func ApplyScheduleTemplateHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { rkID, err := strconv.ParseInt(c.Params("rkId"), 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class rkId"}) } created := 0 skipped := 0 for day := 0; day <= 4; day++ { for _, slot := range standardShiftTemplate { var existing int64 db.Model(&models.ClassSchedule{}). Where("class_rk_id = ? AND day_of_week = ? AND period = ?", rkID, day, slot.Period). Count(&existing) if existing > 0 { skipped++ continue } row := models.ClassSchedule{ ClassRkID: rkID, DayOfWeek: day, Period: slot.Period, StartTime: slot.StartTime, EndTime: slot.EndTime, CourseID: 0, CourseName: "", IsActive: false, } if err := db.Create(&row).Error; err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } created++ } } return c.JSON(fiber.Map{"ok": true, "created": created, "skipped": skipped}) } } type attendanceRow struct { StudentRkID int64 `json:"studentRkId"` StudentCode string `json:"studentCode"` FullName string `json:"fullName"` Email string `json:"email"` Status int `json:"status"` StatusLabel string `json:"statusLabel"` OnlineMinutes int `json:"onlineMinutes"` StatusEditedByTeacher bool `json:"statusEditedByTeacher"` PushedToQLDTAt *time.Time `json:"pushedToQldtAt,omitempty"` } func resolveScheduleForPeriod(db *gorm.DB, classRkID int64, dayOfWeek, period int) (*models.ClassSchedule, error) { var row models.ClassSchedule err := db.Where("class_rk_id = ? AND day_of_week = ? AND period = ?", classRkID, dayOfWeek, period).First(&row).Error if err == nil { return &row, nil } var slots []models.ClassSchedule if err := db.Where("class_rk_id = ? AND day_of_week = ?", classRkID, dayOfWeek). Order("start_time").Find(&slots).Error; err != nil { return nil, err } if period < 1 || period > len(slots) { return nil, gorm.ErrRecordNotFound } return &slots[period-1], nil } func recalcAttendanceForPeriod(db *gorm.DB, classRkID int64, date string, period int, dayOfWeek int) error { sched, err := resolveScheduleForPeriod(db, classRkID, dayOfWeek, period) if err != nil { return err } shiftMins := qldt.ShiftDurationMinutes(sched.StartTime, sched.EndTime) var mappings []models.ClassStudent if err := db.Where("class_rk_id = ?", classRkID).Find(&mappings).Error; err != nil { return err } for _, m := range mappings { var session models.StudentSession onlineMins := 0 periods := []int{period} if period == 1 { periods = append(periods, 0) } err := db.Where("student_rk_id = ? AND class_rk_id = ? AND session_date = ? AND period IN ?", m.StudentRkID, classRkID, date, periods). Order("period DESC").First(&session).Error if err == nil { onlineMins = session.OnlineSeconds / 60 } status := qldt.InferStatusFromOnlineMinutes(onlineMins, shiftMins) label := qldt.AttendanceStatusLabel(status) row := models.AttendanceResult{ ClassRkID: classRkID, SessionDate: date, Period: period, StudentRkID: m.StudentRkID, Status: status, StatusLabel: label, OnlineMinutes: onlineMins, } if err := db.Clauses(clause.OnConflict{ Columns: []clause.Column{ {Name: "class_rk_id"}, {Name: "session_date"}, {Name: "period"}, {Name: "student_rk_id"}, }, DoUpdates: clause.Assignments(map[string]interface{}{ "online_minutes": gorm.Expr("VALUES(online_minutes)"), "status": gorm.Expr("IF(status_edited_by_teacher, status, VALUES(status))"), "status_label": gorm.Expr("IF(status_edited_by_teacher, status_label, VALUES(status_label))"), "updated_at": time.Now(), }), }).Create(&row).Error; err != nil { return err } } return nil } func scheduleDayFromDate(date string) (int, error) { t, err := time.Parse("2006-01-02", date) if err != nil { return 0, err } wd := t.Weekday() if wd == time.Sunday { return 6, nil } return int(wd) - 1, nil } // GET /api/classes/:rkId/attendance?date=&period= func GetClassAttendanceHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { classRkID, _ := strconv.ParseInt(c.Params("rkId"), 10, 64) date := c.Query("date", qldt.NowDateVN()) period, _ := strconv.Atoi(c.Query("period", "1")) if period < 1 { period = 1 } dayOfWeek, err := scheduleDayFromDate(date) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid date"}) } _ = recalcAttendanceForPeriod(db, classRkID, date, period, dayOfWeek) var mappings []models.ClassStudent db.Where("class_rk_id = ?", classRkID).Find(&mappings) studentIDs := make([]int64, 0, len(mappings)) for _, m := range mappings { studentIDs = append(studentIDs, m.StudentRkID) } if len(studentIDs) == 0 { return c.JSON(fiber.Map{"data": []any{}, "period": period, "date": date}) } var students []models.Student db.Where("rk_id IN ?", studentIDs).Find(&students) var results []models.AttendanceResult db.Where("class_rk_id = ? AND session_date = ? AND period = ?", classRkID, date, period).Find(&results) resMap := map[int64]models.AttendanceResult{} for _, r := range results { resMap[r.StudentRkID] = r } out := make([]attendanceRow, 0, len(students)) for _, st := range students { r, ok := resMap[st.RkID] row := attendanceRow{ StudentRkID: st.RkID, StudentCode: st.StudentCode, FullName: st.FullName, Email: st.Email, } if ok { row.Status = r.Status row.StatusLabel = r.StatusLabel row.OnlineMinutes = r.OnlineMinutes row.StatusEditedByTeacher = r.StatusEditedByTeacher row.PushedToQLDTAt = r.PushedToQLDTAt } out = append(out, row) } var shiftPushedAt *time.Time qldtDirty := false for _, r := range results { if r.PushedToQLDTAt != nil { if shiftPushedAt == nil || r.PushedToQLDTAt.After(*shiftPushedAt) { t := *r.PushedToQLDTAt shiftPushedAt = &t } if r.UpdatedAt.After(*r.PushedToQLDTAt) { qldtDirty = true } } } sched, _ := resolveScheduleForPeriod(db, classRkID, dayOfWeek, period) shiftInfo := fiber.Map{ "pushedToQldtAt": shiftPushedAt, "qldtDirty": qldtDirty, } if sched != nil { shiftInfo["startTime"] = sched.StartTime shiftInfo["endTime"] = sched.EndTime shiftInfo["courseId"] = sched.CourseID shiftInfo["courseName"] = sched.CourseName shiftInfo["isActive"] = sched.IsActive } return c.JSON(fiber.Map{"data": out, "period": period, "date": date, "shift": shiftInfo}) } } // PUT /api/classes/:rkId/attendance/status func UpdateAttendanceStatusHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { classRkID, _ := strconv.ParseInt(c.Params("rkId"), 10, 64) var req struct { Date string `json:"date"` Period int `json:"period"` StudentRkID int64 `json:"studentRkId"` Status int `json:"status"` } if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"}) } if req.Date == "" { req.Date = qldt.NowDateVN() } if req.Period < 1 { req.Period = 1 } label := qldt.AttendanceStatusLabel(req.Status) var row models.AttendanceResult err := db.Where("class_rk_id = ? AND session_date = ? AND period = ? AND student_rk_id = ?", classRkID, req.Date, req.Period, req.StudentRkID).First(&row).Error if err == gorm.ErrRecordNotFound { row = models.AttendanceResult{ ClassRkID: classRkID, SessionDate: req.Date, Period: req.Period, StudentRkID: req.StudentRkID, Status: req.Status, StatusLabel: label, StatusEditedByTeacher: true, } if err := db.Create(&row).Error; err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } } else if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } else { row.Status = req.Status row.StatusLabel = label row.StatusEditedByTeacher = true if err := db.Save(&row).Error; err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } } return c.JSON(fiber.Map{"ok": true, "data": row}) } } // PUT /api/classes/:rkId/attendance/bulk-status func UpdateAttendanceBulkStatusHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { classRkID, _ := strconv.ParseInt(c.Params("rkId"), 10, 64) var req struct { Date string `json:"date"` Period int `json:"period"` StudentRkIDs []int64 `json:"studentRkIds"` Status int `json:"status"` } if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"}) } if req.Date == "" { req.Date = qldt.NowDateVN() } if req.Period < 1 { req.Period = 1 } if len(req.StudentRkIDs) == 0 { return c.JSON(fiber.Map{"ok": true, "message": "No students selected"}) } label := qldt.AttendanceStatusLabel(req.Status) tx := db.Begin() for _, studentRkID := range req.StudentRkIDs { var row models.AttendanceResult err := tx.Where("class_rk_id = ? AND session_date = ? AND period = ? AND student_rk_id = ?", classRkID, req.Date, req.Period, studentRkID).First(&row).Error if err == gorm.ErrRecordNotFound { row = models.AttendanceResult{ ClassRkID: classRkID, SessionDate: req.Date, Period: req.Period, StudentRkID: studentRkID, Status: req.Status, StatusLabel: label, StatusEditedByTeacher: true, } if err := tx.Create(&row).Error; err != nil { tx.Rollback() return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } } else if err != nil { tx.Rollback() return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } else { row.Status = req.Status row.StatusLabel = label row.StatusEditedByTeacher = true if err := tx.Save(&row).Error; err != nil { tx.Rollback() return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } } } if err := tx.Commit().Error; err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{"ok": true}) } } // POST /api/classes/:rkId/attendance/push-qldt func PushAttendanceToQLDTHandler(db *gorm.DB, qldtClient *qldt.Client) fiber.Handler { return func(c *fiber.Ctx) error { classRkID, _ := strconv.ParseInt(c.Params("rkId"), 10, 64) var req struct { Date string `json:"date"` Period int `json:"period"` } if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"}) } if req.Date == "" { req.Date = qldt.NowDateVN() } if req.Period < 1 { req.Period = 1 } token := GetQldtToken(db) if token == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "QLDT_TOKEN chưa cấu hình"}) } dayOfWeek, _ := scheduleDayFromDate(req.Date) sched, err := resolveScheduleForPeriod(db, classRkID, dayOfWeek, req.Period) if err != nil || sched == nil || sched.CourseID == 0 { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Ca học chưa cấu hình môn học (courseId)"}) } userID, err := qldt.ParseUserIDFromToken(token) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Không đọc được users_id từ QLDT_TOKEN: " + err.Error()}) } var results []models.AttendanceResult db.Where("class_rk_id = ? AND session_date = ? AND period = ?", classRkID, req.Date, req.Period).Find(&results) if len(results) == 0 { _ = recalcAttendanceForPeriod(db, classRkID, req.Date, req.Period, dayOfWeek) db.Where("class_rk_id = ? AND session_date = ? AND period = ?", classRkID, req.Date, req.Period).Find(&results) } students := make([]qldt.AttendanceStudent, 0, len(results)) for _, r := range results { students = append(students, qldt.AttendanceStudent{ StudentID: r.StudentRkID, Status: r.Status, }) } payload := &qldt.AttendancePayload{ UsersID: userID, CoursesID: int(sched.CourseID), Period: req.Period, Students: students, ClassID: classRkID, Date: req.Date, Type: "OFFLINE", } body, code, err := qldtClient.SaveAttendance(context.Background(), token, payload) if err != nil { return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": err.Error(), "statusCode": code, "body": string(body)}) } now := time.Now() db.Model(&models.AttendanceResult{}). Where("class_rk_id = ? AND session_date = ? AND period = ?", classRkID, req.Date, req.Period). Update("pushed_to_qldt_at", now) return c.JSON(fiber.Map{"ok": true, "message": "Đã đẩy điểm danh lên QLĐT", "studentCount": len(students)}) } } // GET /api/classes/:rkId/attendance/shifts?date= func ListAttendanceShiftsHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { classRkID, _ := strconv.ParseInt(c.Params("rkId"), 10, 64) date := c.Query("date", qldt.NowDateVN()) dayOfWeek, err := scheduleDayFromDate(date) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid date"}) } var slots []models.ClassSchedule db.Where("class_rk_id = ? AND day_of_week = ?", classRkID, dayOfWeek). Order("period, start_time").Find(&slots) if len(slots) == 0 { for p := 1; p <= 4; p++ { slots = append(slots, models.ClassSchedule{ Period: p, DayOfWeek: dayOfWeek, IsActive: false, }) } } out := make([]fiber.Map, 0, len(slots)) for _, s := range slots { period := s.Period if period == 0 { period = 1 } out = append(out, fiber.Map{ "period": period, "startTime": s.StartTime, "endTime": s.EndTime, "courseId": s.CourseID, "courseName": s.CourseName, "isActive": s.IsActive, }) } return c.JSON(fiber.Map{"data": out, "date": date, "dayOfWeek": dayOfWeek}) } } // GET /api/classes/:rkId/leave-requests func GetLeaveRequestsHandler(db *gorm.DB, qldtClient *qldt.Client) fiber.Handler { return func(c *fiber.Ctx) error { classRkID, _ := strconv.ParseInt(c.Params("rkId"), 10, 64) courseID, _ := strconv.ParseInt(c.Query("courseId"), 10, 64) date := c.Query("date", qldt.NowDateVN()) if courseID == 0 { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Missing courseId"}) } token := GetQldtToken(db) if token == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "QLDT_TOKEN chưa cấu hình"}) } body, err := qldtClient.GetLeaveRequests(context.Background(), token, classRkID, courseID, date) if err != nil { return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": err.Error()}) } var data []map[string]interface{} if err := json.Unmarshal(body, &data); err != nil { var fallback any if errFallback := json.Unmarshal(body, &fallback); errFallback == nil { return c.JSON(fallback) } return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to parse QLDT response"}) } // Ghi đè trạng thái duyệt local nếu có var localReqs []models.LocalLeaveRequest if err := db.Find(&localReqs).Error; err == nil && len(localReqs) > 0 { localMap := make(map[int64]string) for _, lr := range localReqs { localMap[lr.LeaveID] = lr.Status } for i, item := range data { if idVal, ok := item["id"]; ok { var idInt int64 switch v := idVal.(type) { case float64: idInt = int64(v) case int64: idInt = v case int: idInt = int64(v) } if localStatus, found := localMap[idInt]; found { data[i]["status"] = localStatus } } } } return c.JSON(data) } } // POST /api/classes/:rkId/leave-requests/:leaveId/status func UpdateLeaveStatusHandler(db *gorm.DB, qldtClient *qldt.Client) fiber.Handler { return func(c *fiber.Ctx) error { classRkID, _ := strconv.ParseInt(c.Params("rkId"), 10, 64) leaveID, _ := strconv.ParseInt(c.Params("leaveId"), 10, 64) var req struct { Status string `json:"status"` // "Phê duyệt" or "Từ chối" StudentRkID int64 `json:"studentRkId"` Date string `json:"date"` Period int `json:"period"` } if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid body payload"}) } if req.Status != "Phê duyệt" && req.Status != "Từ chối" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Trạng thái phê duyệt không hợp lệ"}) } // 1. Lưu trạng thái đơn phép vào database local thay vì gọi QLDT Portal API var localLeave models.LocalLeaveRequest errLocal := db.Where("leave_id = ?", leaveID).First(&localLeave).Error if errLocal == gorm.ErrRecordNotFound { localLeave = models.LocalLeaveRequest{ LeaveID: leaveID, Status: req.Status, } if err := db.Create(&localLeave).Error; err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Lỗi lưu đơn phép local: " + err.Error()}) } } else if errLocal == nil { localLeave.Status = req.Status if err := db.Save(&localLeave).Error; err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Lỗi cập nhật đơn phép local: " + err.Error()}) } } else { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": errLocal.Error()}) } // 2. Nếu status là "Phê duyệt", tự động cập nhật điểm danh trong hệ thống của chúng ta thành "Nghỉ có phép" if req.Status == "Phê duyệt" { if req.StudentRkID == 0 || req.Date == "" || req.Period == 0 { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Thiếu thông tin cập nhật điểm danh (studentRkId, date, period)"}) } dayOfWeek, err := scheduleDayFromDate(req.Date) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Ngày không hợp lệ"}) } sched, err := resolveScheduleForPeriod(db, classRkID, dayOfWeek, req.Period) if err != nil || sched == nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Không tìm thấy lịch học cho ca này"}) } // Cập nhật hoặc Tạo mới AttendanceResult var row models.AttendanceResult errFind := db.Where("class_rk_id = ? AND session_date = ? AND period = ? AND student_rk_id = ?", classRkID, req.Date, req.Period, req.StudentRkID).First(&row).Error if errFind == nil { // Cập nhật hàng có sẵn row.Status = 1 // Nghỉ có phép row.StatusLabel = "Nghỉ có phép" row.StatusEditedByTeacher = true if err := db.Save(&row).Error; err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Lỗi lưu điểm danh: " + err.Error()}) } } else if errFind == gorm.ErrRecordNotFound { // Tạo mới hàng row = models.AttendanceResult{ ClassRkID: classRkID, SessionDate: req.Date, Period: req.Period, StudentRkID: req.StudentRkID, Status: 1, StatusLabel: "Nghỉ có phép", StatusEditedByTeacher: true, } if err := db.Create(&row).Error; err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Lỗi tạo điểm danh: " + err.Error()}) } } else { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": errFind.Error()}) } } return c.JSON(fiber.Map{"ok": true, "message": "Cập nhật đơn xin nghỉ thành công!"}) } }