core v1
This commit is contained in:
462
server/internal/handlers/handlers_attendance.go
Normal file
462
server/internal/handlers/handlers_attendance.go
Normal file
@@ -0,0 +1,462 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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, token string) 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"})
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
|
||||
sched, _ := resolveScheduleForPeriod(db, classRkID, dayOfWeek, period)
|
||||
shiftInfo := fiber.Map{}
|
||||
if sched != nil {
|
||||
shiftInfo = fiber.Map{
|
||||
"startTime": sched.StartTime,
|
||||
"endTime": sched.EndTime,
|
||||
"courseId": sched.CourseID,
|
||||
"courseName": sched.CourseName,
|
||||
"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})
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/classes/:rkId/attendance/push-qldt
|
||||
func PushAttendanceToQLDTHandler(db *gorm.DB, qldtClient *qldt.Client, token string) 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
|
||||
}
|
||||
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})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user