package controllers import ( "encoding/json" "io" "strings" "time" "server/models" "server/pkg/identity" "server/pkg/jwtutil" authSvc "server/services/auth" "github.com/beego/beego/v2/client/orm" ) // 移动端 OA 考勤打卡接口(挂在 AppOaController 上)。 // // 与 Web 后台 /backend/oa/attendance/* 共用同一套数据与判定逻辑: // - 打卡:oaAttendPunch(联动排班明细,匹配不上只提醒、仍可签到); // - 记录:本人维度,只能看到自己的考勤。 // // 员工档案缺失时仍允许打卡(employee_id=0,按登录身份 uid 归档), // 保证「排班 / 人事档案没配好也能先签到」,由管理员后续核对。 // oaAttendAppClaims 取 app 令牌并确认已归属租户;失败时返回可直接输出的状态码与文案。 func (c *AppOaController) oaAttendAppClaims() (*jwtutil.Claims, int, string) { claims, err := c.oaClaims() if err != nil { code := identity.Status(err) return nil, code, err.Error() } if claims.TenantId <= 0 { return nil, 403, "当前账号未绑定企业,无法打卡" } return claims, 200, "" } // oaAttendSelf 解析当前登录者对应的员工档案;未绑定时返回 nil 与兜底姓名。 func (c *AppOaController) oaAttendSelf(claims *jwtutil.Claims) (*models.BackendEmployee, string) { tid := uint64(claims.TenantId) uid := uint64(claims.UserID) if employee, ok := findSelfEmployee(tid, uid, claims.Username); ok { return &employee, employee.Name } name := strings.TrimSpace(claims.Username) if bind, err := authSvc.GetTenantUser(uid, tid); err == nil && bind != nil { if v := authBindValue(bind.Name); v != "" { name = v } else if v := authBindValue(bind.Account); v != "" { name = v } } return nil, name } // oaAttendEmployeeMap 当前登录者的员工信息(未绑定时 bound=false)。 func oaAttendEmployeeMap(tid uint64, employee *models.BackendEmployee, fallbackName string) map[string]interface{} { if employee == nil { return map[string]interface{}{ "id": 0, "name": fallbackName, "account": "", "org_id": 0, "org_name": "", "position": "", "bound": false, } } orgID := scEmployeeDepartmentID(*employee) position := "" if employee.Position != nil { position = strings.TrimSpace(*employee.Position) } return map[string]interface{}{ "id": employee.ID, "name": employee.Name, "account": employee.Account, "org_id": orgID, "org_name": oaAttendOrgName(tid, orgID), "position": position, "bound": true, } } // oaAttendEntryMap 当日排班(未排班返回 nil)。 func oaAttendEntryMap(entry *models.OaScheduleEntry) map[string]interface{} { if entry == nil { return nil } return map[string]interface{}{ "id": entry.ID, "shift_id": entry.ShiftID, "shift_name": entry.ShiftName, "short_name": entry.ShortName, "color": entry.Color, "work_start": entry.WorkStart, "work_end": entry.WorkEnd, "shift_text": oaAttendShiftText(entry.WorkStart, entry.WorkEnd), "source": entry.Source, "note": entry.Note, } } // oaAttendTodayWarning 未匹配排班时的提醒文案(页面顶部横幅 + 打卡前二次确认共用)。 func oaAttendTodayWarning(entry *models.OaScheduleEntry, employee *models.BackendEmployee) string { if entry != nil { return "" } if employee == nil { return "未匹配到你的员工档案与今日排班,仍可签到,签到后将标记为「无排班打卡」" } return "今日未匹配到排班,仍可签到,签到后将标记为「无排班打卡」,请联系管理员核对排班" } // oaAttendRecentDays 最近 N 天的排班 + 打卡合并视图(一次性批量查询,避免逐天查库)。 func oaAttendRecentDays(tid, uid uint64, employeeID uint, endDate string, days int) []map[string]interface{} { if days < 1 || days > 31 { days = 7 } startDate := nextOaDate(endDate, -(days - 1)) if startDate == "" { startDate = endDate } entries := map[string]*models.OaScheduleEntry{} if employeeID > 0 { var entryRows []models.OaScheduleEntry _, _ = models.Orm.QueryTable(new(models.OaScheduleEntry)). Filter("tid", tid). Filter("employee_id", employeeID). Filter("is_deleted", 0). Filter("schedule_date__gte", startDate). Filter("schedule_date__lte", endDate). All(&entryRows) for i := range entryRows { entries[normalizeOaDate(entryRows[i].ScheduleDate)] = &entryRows[i] } } records := map[string]*models.OaAttendance{} qs := oaAttendQuery(tid). Filter("attend_date__gte", startDate). Filter("attend_date__lte", endDate) if employeeID > 0 { qs = qs.Filter("employee_id", employeeID) } else if uid > 0 { qs = qs.Filter("uid", uid) } var recordRows []models.OaAttendance _, _ = qs.All(&recordRows) for i := range recordRows { records[normalizeOaDate(recordRows[i].AttendDate)] = &recordRows[i] } list := make([]map[string]interface{}, 0, days) today := time.Now().Format("2006-01-02") for date := startDate; date != "" && date <= endDate; date = nextOaDate(date, 1) { entry := entries[date] record := records[date] item := map[string]interface{}{ "date": date, "weekday": oaAttendWeekday(date), "is_today": date == today, "has_schedule": entry != nil, "matched": entry != nil, "shift_name": "", "shift_text": "", "color": "", "clock_in_hm": "", "clock_out_hm": "", "day_status": oaDayStatusRest, "day_status_text": "休息", } if entry != nil { item["shift_name"] = entry.ShiftName item["shift_text"] = oaAttendShiftText(entry.WorkStart, entry.WorkEnd) item["color"] = entry.Color } if record != nil { view := oaAttendView(record) item["shift_name"] = view.ShiftName item["shift_text"] = view.ShiftText item["color"] = view.Color item["clock_in_hm"] = view.ClockInHm item["clock_out_hm"] = view.ClockOutHm item["day_status"] = view.DayStatus item["day_status_text"] = view.DayStatusText item["has_schedule"] = entry != nil || view.MatchStatus == models.OaAttendMatchOK } else if entry != nil { item["day_status"] = oaDayStatusMissing item["day_status_text"] = "未打卡" } list = append(list, item) } // 倒序(最近的日期在前) for i, j := 0, len(list)-1; i < j; i, j = i+1, j-1 { list[i], list[j] = list[j], list[i] } return list } // oaAttendPersonalStats 个人区间考勤统计(员工档案缺失时只统计打卡次数)。 func oaAttendPersonalStats(tid, uid uint64, employeeID uint, startDate, endDate string) map[string]interface{} { baseCond := func() *orm.Condition { cond := orm.NewCondition(). And("tid", tid). And("attend_date__gte", startDate). And("attend_date__lte", endDate) if employeeID > 0 { cond = cond.And("employee_id", employeeID) } else { cond = cond.And("uid", uid) } return cond } countWith := func(extra *orm.Condition) int64 { cond := baseCond() if extra != nil { cond = cond.AndCond(extra) } n, _ := models.Orm.QueryTable(new(models.OaAttendance)).SetCond(cond).Count() return n } scheduled := 0 if employeeID > 0 { n, _ := models.Orm.QueryTable(new(models.OaScheduleEntry)). Filter("tid", tid). Filter("employee_id", employeeID). Filter("is_deleted", 0). Filter("schedule_date__gte", startDate). Filter("schedule_date__lte", endDate). Count() scheduled = int(n) } clocked := countWith(orm.NewCondition().And("clock_in_time__isnull", false)) late := countWith(orm.NewCondition().And("clock_in_status", models.OaAttendClockLate)) early := countWith(orm.NewCondition().And("clock_out_status", models.OaAttendClockLate)) noSchedule := countWith(orm.NewCondition().And("match_status", models.OaAttendMatchMissing)) matchedClocked := countWith(orm.NewCondition(). And("clock_in_time__isnull", false). And("match_status", models.OaAttendMatchOK)) missing := scheduled - int(matchedClocked) if missing < 0 { missing = 0 } rate := 0 if scheduled > 0 { rate = int(matchedClocked) * 100 / scheduled } return map[string]interface{}{ "scheduled": scheduled, "clocked": clocked, "late": late, "early": early, "noschedule": noSchedule, "missing": missing, "attendance_rate": rate, } } // AttendanceToday GET /api/oa/attendance/today?days=7 // 今日打卡状态 + 当日排班 + 最近 N 天记录 + 本月统计。 // 未匹配排班时 warning 非空,前端据此提醒,但 can_clock_in/can_clock_out 仍为 true。 func (c *AppOaController) AttendanceToday() { claims, code, msg := c.oaAttendAppClaims() if msg != "" { c.oaErr(code, code, msg) return } tid := uint64(claims.TenantId) uid := uint64(claims.UserID) days, _ := c.GetInt("days", 7) now := time.Now() date := now.Format("2006-01-02") employee, fallbackName := c.oaAttendSelf(claims) employeeID := uint(0) if employee != nil { employeeID = employee.ID } entry := oaAttendEntryOf(tid, employeeID, date) record := oaAttendFind(tid, date, employeeID, uid) var recordView interface{} if record != nil { recordView = oaAttendView(record) } canClockIn := record == nil || record.ClockInTime == nil canClockOut := record == nil || record.ClockOutTime == nil monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).Format("2006-01-02") c.oaOk(map[string]interface{}{ "date": date, "now": now.Format("2006-01-02 15:04:05"), "employee": oaAttendEmployeeMap(tid, employee, fallbackName), "entry": oaAttendEntryMap(entry), "record": recordView, "matched": entry != nil, "warning": oaAttendTodayWarning(entry, employee), "can_clock_in": canClockIn, "can_clock_out": canClockOut, "recent": oaAttendRecentDays(tid, uid, employeeID, date, days), "stats": oaAttendPersonalStats(tid, uid, employeeID, monthStart, date), }) } // AttendanceClock POST /api/oa/attendance/clock // body: {"type":"in|out","location":"...","remark":"..."} // 未匹配排班时会返回 warning,但打卡依然成功(match_status=2,clock_*_status=3)。 func (c *AppOaController) AttendanceClock() { claims, code, msg := c.oaAttendAppClaims() if msg != "" { c.oaErr(code, code, msg) return } var payload struct { Type string `json:"type"` Location string `json:"location"` Remark string `json:"remark"` } if raw, readErr := io.ReadAll(c.Ctx.Request.Body); readErr == nil && len(raw) > 0 { if json.Unmarshal(raw, &payload) != nil { c.oaErr(400, 400, "参数错误") return } } employee, fallbackName := c.oaAttendSelf(claims) result, code, msg := oaAttendPunch(uint64(claims.TenantId), uint64(claims.UserID), employee, oaAttendPunchInput{ Type: payload.Type, Location: strings.TrimSpace(payload.Location), Remark: strings.TrimSpace(payload.Remark), FallbackName: fallbackName, }) if msg != "" { c.oaErr(code, code, msg) return } c.oaOk(map[string]interface{}{ "record": result.View, "warning": result.Warning, "duplicated": result.Duplicated, "message": result.Message, }) } // AttendanceRecords GET /api/oa/attendance/records?page=&pageSize=&start_date=&end_date= // 本人考勤记录(默认本月),返回 list/total/stats。 func (c *AppOaController) AttendanceRecords() { claims, code, msg := c.oaAttendAppClaims() if msg != "" { c.oaErr(code, code, msg) return } tid := uint64(claims.TenantId) uid := uint64(claims.UserID) page, _ := c.GetInt("page", 1) pageSize, _ := c.GetInt("pageSize", 20) if page < 1 { page = 1 } if pageSize < 1 || pageSize > 100 { pageSize = 20 } now := time.Now() startDate := strings.TrimSpace(c.GetString("start_date")) endDate := strings.TrimSpace(c.GetString("end_date")) if startDate == "" || endDate == "" { startDate = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).Format("2006-01-02") endDate = now.Format("2006-01-02") } dates, msg := scDateSpan(startDate, endDate) if msg != "" { c.oaErr(400, 400, msg) return } if len(dates) > oaAttendMaxRangeDays { c.oaErr(400, 400, "单次最多查询 92 天") return } employee, _ := c.oaAttendSelf(claims) employeeID := uint(0) if employee != nil { employeeID = employee.ID } cond := orm.NewCondition(). And("tid", tid). And("attend_date__gte", startDate). And("attend_date__lte", endDate) if employeeID > 0 { cond = cond.And("employee_id", employeeID) } else { cond = cond.And("uid", uid) } qs := models.Orm.QueryTable(new(models.OaAttendance)).SetCond(cond) total, _ := qs.Count() var rows []models.OaAttendance _, queryErr := qs.OrderBy("-attend_date", "id"). Limit(pageSize).Offset((page - 1) * pageSize).All(&rows) if queryErr != nil && queryErr != orm.ErrNoRows { c.oaErr(500, 500, "查询考勤记录失败") return } list := make([]oaAttendanceView, 0, len(rows)) for i := range rows { list = append(list, oaAttendView(&rows[i])) } c.oaOk(map[string]interface{}{ "list": list, "total": total, "start_date": startDate, "end_date": endDate, "stats": oaAttendPersonalStats(tid, uid, employeeID, startDate, endDate), }) } // AttendanceStats GET /api/oa/attendance/stats?period=month|quarter|year&date=YYYY-MM-DD // 个人考勤卡:按「月 / 季 / 年」聚合,供移动端统计卡片切换维度。 func (c *AppOaController) AttendanceStats() { claims, code, msg := c.oaAttendAppClaims() if msg != "" { c.oaErr(code, code, msg) return } tid := uint64(claims.TenantId) uid := uint64(claims.UserID) period := strings.ToLower(strings.TrimSpace(c.GetString("period"))) if period != "month" && period != "quarter" && period != "year" { period = "month" } anchor := strings.TrimSpace(c.GetString("date")) if !isValidOaDate(anchor) { anchor = time.Now().Format("2006-01-02") } startDate, endDate, label, rangeOK := oaAttendPeriodRange(period, anchor) if !rangeOK { c.oaErr(400, 400, "日期参数无效") return } endDate = oaAttendClampRange(startDate, endDate) employee, _ := c.oaAttendSelf(claims) employeeID := uint(0) if employee != nil { employeeID = employee.ID } c.oaOk(map[string]interface{}{ "period": period, "label": label, "anchor": anchor, "start_date": startDate, "end_date": endDate, "stats": oaAttendPersonalStats(tid, uid, employeeID, startDate, endDate), }) }