package controllers import ( "encoding/json" "fmt" "strconv" "strings" "time" "server/models" "server/pkg/identity" "server/pkg/jwtutil" "github.com/beego/beego/v2/client/orm" beego "github.com/beego/beego/v2/server/web" ) // OA 考勤打卡:移动端签到 / 签退(app_oa_attendance.go)+ 后台考勤记录(本文件)。 // // 打卡与排班联动的方式: // 1. 打卡时按「员工 + 日期」查排班明细(yz_backend_oa_schedule_entry), // 命中则把班次名称与上下班时间快照进考勤记录,并据此判定正常 / 迟到 / 早退; // 2. 未命中排班(含员工档案缺失)不影响打卡,只把 match_status 记为 2、 // 在 clock_in_note 里写入提醒文案,前端据此做「先提醒、仍可签到」的交互; // 3. 后台只读考勤记录 + 缺卡名单,异常说明由管理员在备注里补充。 const ( // oaAttendGraceMinutes 迟到 / 早退容差,避免卡点打卡被判异常。 oaAttendGraceMinutes = 5 // oaAttendEarlyLimitMinutes 允许提前签到的分钟数:超过则记为「不在班次时段」。 oaAttendEarlyLimitMinutes = 60 // oaAttendMaxRangeDays 单次查询的日期区间上限。 oaAttendMaxRangeDays = 92 // oaAttendMaxPageSize 分页大小上限。 oaAttendMaxPageSize = 200 ) // 考勤「当日结果」:列表与统计统一按这个口径展示。 const ( oaDayStatusNormal int8 = 1 // 正常 oaDayStatusLate int8 = 2 // 迟到 oaDayStatusEarly int8 = 3 // 早退 oaDayStatusMissing int8 = 4 // 缺卡(未签到) oaDayStatusNoSchedule int8 = 5 // 无排班打卡 oaDayStatusOffTime int8 = 6 // 不在班次时段 oaDayStatusNoClockOut int8 = 7 // 未签退 oaDayStatusRest int8 = 8 // 未排班且未打卡(休息) ) // oaAttendanceView 考勤记录的对外结构:时间统一转成字符串,避免前端解析 RFC3339。 type oaAttendanceView struct { ID uint64 `json:"id"` Uid uint64 `json:"uid"` EmployeeID uint `json:"employee_id"` EmployeeName string `json:"employee_name"` EmployeeAccount string `json:"employee_account"` OrgID uint64 `json:"org_id"` OrgName string `json:"org_name"` Position string `json:"position"` AttendDate string `json:"attend_date"` Weekday int `json:"weekday"` EntryID uint64 `json:"entry_id"` ShiftID uint64 `json:"shift_id"` ShiftName string `json:"shift_name"` ShortName string `json:"short_name"` Color string `json:"color"` WorkStart string `json:"work_start"` WorkEnd string `json:"work_end"` ShiftText string `json:"shift_text"` MatchStatus int8 `json:"match_status"` ClockInTime string `json:"clock_in_time"` ClockInHm string `json:"clock_in_hm"` ClockInStatus int8 `json:"clock_in_status"` ClockInText string `json:"clock_in_text"` ClockInNote string `json:"clock_in_note"` ClockOutTime string `json:"clock_out_time"` ClockOutHm string `json:"clock_out_hm"` ClockOutStatus int8 `json:"clock_out_status"` ClockOutText string `json:"clock_out_text"` ClockOutNote string `json:"clock_out_note"` Location string `json:"location"` Remark string `json:"remark"` Source int8 `json:"source"` DayStatus int8 `json:"day_status"` DayStatusText string `json:"day_status_text"` CreateTime string `json:"create_time"` } // BackendOaAttendanceController 后台考勤记录(只读 + 备注)。 type BackendOaAttendanceController struct { beego.Controller } // --------------------------------------------------------------------------- // 认证与响应 // --------------------------------------------------------------------------- func (c *BackendOaAttendanceController) atClaims() (*jwtutil.Claims, bool) { claims, err := identity.Claims(c.Ctx.Request.Header.Get("Authorization"), identity.EndBackend) if err != nil || claims.TenantId <= 0 { return nil, false } return claims, true } func (c *BackendOaAttendanceController) atOperator() (uint64, uint64, bool) { claims, ok := c.atClaims() if !ok { c.atFail(401, 401, "未登录或登录已过期") return 0, 0, false } return uint64(claims.TenantId), uint64(claims.UserID), true } func (c *BackendOaAttendanceController) atOK(data interface{}) { c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data} _ = c.ServeJSON() } func (c *BackendOaAttendanceController) atFail(httpStatus, bizCode int, msg string) { if httpStatus > 0 { c.Ctx.Output.SetStatus(httpStatus) } c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} _ = c.ServeJSON() } // --------------------------------------------------------------------------- // 通用工具 // --------------------------------------------------------------------------- // oaAttendQuery 本租户考勤记录查询。 func oaAttendQuery(tid uint64) orm.QuerySeter { return models.Orm.QueryTable(new(models.OaAttendance)).Filter("tid", tid) } // oaAttendFind 取某人某天的考勤记录:有员工档案按员工,否则按登录身份兜底。 func oaAttendFind(tid uint64, date string, employeeID uint, uid uint64) *models.OaAttendance { qs := oaAttendQuery(tid).Filter("attend_date", date) switch { case employeeID > 0: qs = qs.Filter("employee_id", employeeID) case uid > 0: qs = qs.Filter("uid", uid) default: return nil } var row models.OaAttendance if err := qs.OrderBy("-id").Limit(1).One(&row); err != nil { return nil } return &row } // oaAttendEntryOf 取某员工某天的排班明细(一人一天一条有效记录)。 func oaAttendEntryOf(tid uint64, employeeID uint, date string) *models.OaScheduleEntry { if employeeID == 0 { return nil } var rows []models.OaScheduleEntry _, err := models.Orm.QueryTable(new(models.OaScheduleEntry)). Filter("tid", tid). Filter("employee_id", employeeID). Filter("schedule_date", date). Filter("is_deleted", 0). OrderBy("-id").Limit(1).All(&rows) if err != nil || len(rows) == 0 { return nil } entry := rows[0] entry.ScheduleDate = normalizeOaDate(entry.ScheduleDate) return &entry } // oaAttendOrgName 组织名称快照(失败返回空串,不影响打卡)。 func oaAttendOrgName(tid, orgID uint64) string { if orgID == 0 { return "" } var org models.BackendOrganization if err := models.Orm.QueryTable(new(models.BackendOrganization)). Filter("tid", tid).Filter("id", orgID).One(&org); err != nil { return "" } return org.OrgName } // oaAttendOrgIDs 把组织节点展开成「自身 + 下级」的 ID 列表;org_id=0 表示不限。 func oaAttendOrgIDs(tid, root uint64, includeChild bool) []uint64 { if root == 0 { return nil } if !includeChild { return []uint64{root} } var rows []models.BackendOrganization _, err := models.Orm.QueryTable(new(models.BackendOrganization)). Filter("tid", tid). Filter("delete_time__isnull", true). All(&rows) if err != nil { return []uint64{root} } children := map[uint64][]uint64{} for i := range rows { children[rows[i].ParentID] = append(children[rows[i].ParentID], rows[i].ID) } seen := map[uint64]bool{} queue := []uint64{root} for len(queue) > 0 { current := queue[0] queue = queue[1:] if seen[current] { continue } seen[current] = true queue = append(queue, children[current]...) } out := make([]uint64, 0, len(seen)) for id := range seen { out = append(out, id) } return out } // oaAttendWeekday 返回 1=周一 ... 7=周日。 func oaAttendWeekday(date string) int { parsed, ok := scParseDate(date) if !ok { return 0 } weekday := int(parsed.Weekday()) if weekday == 0 { return 7 } return weekday } // oaAttendShiftText 班次时间文案。 func oaAttendShiftText(workStart, workEnd string) string { if workStart == "" || workEnd == "" { return "未排班" } return workStart + "-" + workEnd } func oaAttendClockText(status int8, isOut bool) string { switch status { case models.OaAttendClockNone: return "未打卡" case models.OaAttendClockNormal: return "正常" case models.OaAttendClockLate: if isOut { return "早退" } return "迟到" case models.OaAttendClockNoShift: return "无排班" case models.OaAttendClockOffTime: return "时段异常" } return "未打卡" } // oaAttendDayResult 汇总「当天」的考勤结果,列表与统计共用同一口径。 func oaAttendDayResult(clockIn string, clockInStatus int8, clockOut string, clockOutStatus int8, matchStatus int8) (int8, string) { switch { case clockIn == "": return oaDayStatusMissing, "缺卡" case matchStatus == models.OaAttendMatchMissing: return oaDayStatusNoSchedule, "无排班" case clockInStatus == models.OaAttendClockLate: return oaDayStatusLate, "迟到" case clockOutStatus == models.OaAttendClockLate: return oaDayStatusEarly, "早退" case clockInStatus == models.OaAttendClockOffTime || clockOutStatus == models.OaAttendClockOffTime: return oaDayStatusOffTime, "时段异常" case clockOut == "": return oaDayStatusNoClockOut, "未签退" } return oaDayStatusNormal, "正常" } // oaAttendView 记录 → 视图。 func oaAttendView(row *models.OaAttendance) oaAttendanceView { date := normalizeOaDate(row.AttendDate) inTime, inHm := "", "" if row.ClockInTime != nil { inTime = row.ClockInTime.Format("2006-01-02 15:04:05") inHm = row.ClockInTime.Format("15:04") } outTime, outHm := "", "" if row.ClockOutTime != nil { outTime = row.ClockOutTime.Format("2006-01-02 15:04:05") outHm = row.ClockOutTime.Format("15:04") } dayStatus, dayText := oaAttendDayResult(inHm, row.ClockInStatus, outHm, row.ClockOutStatus, row.MatchStatus) return oaAttendanceView{ ID: row.ID, Uid: row.Uid, EmployeeID: row.EmployeeID, EmployeeName: row.EmployeeName, EmployeeAccount: row.EmployeeAccount, OrgID: row.OrgID, OrgName: row.OrgName, Position: row.Position, AttendDate: date, Weekday: oaAttendWeekday(date), EntryID: row.EntryID, ShiftID: row.ShiftID, ShiftName: row.ShiftName, ShortName: row.ShortName, Color: row.Color, WorkStart: row.WorkStart, WorkEnd: row.WorkEnd, ShiftText: oaAttendShiftText(row.WorkStart, row.WorkEnd), MatchStatus: row.MatchStatus, ClockInTime: inTime, ClockInHm: inHm, ClockInStatus: row.ClockInStatus, ClockInText: oaAttendClockText(row.ClockInStatus, false), ClockInNote: row.ClockInNote, ClockOutTime: outTime, ClockOutHm: outHm, ClockOutStatus: row.ClockOutStatus, ClockOutText: oaAttendClockText(row.ClockOutStatus, true), ClockOutNote: row.ClockOutNote, Location: row.Location, Remark: row.Remark, Source: row.Source, DayStatus: dayStatus, DayStatusText: dayText, CreateTime: row.CreateTime.Format("2006-01-02 15:04:05"), } } // --------------------------------------------------------------------------- // 打卡核心(移动端与后续补录共用) // --------------------------------------------------------------------------- // oaAttendPunchInput 打卡入参。 type oaAttendPunchInput struct { Type string // in=签到 out=签退 Location string Remark string FallbackName string // 员工档案缺失时用于展示的姓名 } // oaAttendPunchResult 打卡结果。 type oaAttendPunchResult struct { View oaAttendanceView Warning string Duplicated bool Message string } // oaShiftWindow 把班次上下班换算成当日分钟区间;下班早于上班视为跨天班次。 func oaShiftWindow(workStart, workEnd string) (int, int, bool) { start, okStart := scMinutes(workStart) end, okEnd := scMinutes(workEnd) if !okStart || !okEnd { return 0, 0, false } if end <= start { end += 24 * 60 } return start, end, true } // oaShiftNowMinutes 把当前时刻换算到班次时间轴上(跨天班次凌晨时刻 +24h)。 func oaShiftNowMinutes(now time.Time, startMinutes int) int { value := now.Hour()*60 + now.Minute() if startMinutes >= 12*60 && value < startMinutes { value += 24 * 60 } return value } // oaAttendClockInStatus 签到结果判定:无排班 / 时段异常只提醒,不阻断打卡。 func oaAttendClockInStatus(row *models.OaAttendance, now time.Time) (int8, string) { start, _, ok := oaShiftWindow(row.WorkStart, row.WorkEnd) if !ok { if row.MatchStatus == models.OaAttendMatchMissing { return models.OaAttendClockNoShift, "今日未匹配到排班,已记录为「无排班打卡」,请联系管理员核对排班" } return models.OaAttendClockOffTime, "班次未配置上下班时间,已记录签到,请核对班次设置" } nowMinutes := oaShiftNowMinutes(now, start) switch { case nowMinutes < start-oaAttendEarlyLimitMinutes: return models.OaAttendClockOffTime, fmt.Sprintf("当前时间不在班次时段内(班次 %s-%s),已记录签到", row.WorkStart, row.WorkEnd) case nowMinutes > start+oaAttendGraceMinutes: return models.OaAttendClockLate, fmt.Sprintf("迟到 %d 分钟(应到 %s),已记录签到", nowMinutes-start, row.WorkStart) } return models.OaAttendClockNormal, "" } // oaAttendClockOutStatus 签退结果判定:早退只提醒,不阻断打卡。 func oaAttendClockOutStatus(row *models.OaAttendance, now time.Time) (int8, string) { start, end, ok := oaShiftWindow(row.WorkStart, row.WorkEnd) if !ok { if row.MatchStatus == models.OaAttendMatchMissing { return models.OaAttendClockNoShift, "今日未匹配到排班,已记录为「无排班签退」,请联系管理员核对排班" } return models.OaAttendClockOffTime, "班次未配置上下班时间,已记录签退,请核对班次设置" } nowMinutes := oaShiftNowMinutes(now, start) if nowMinutes < end-oaAttendGraceMinutes { return models.OaAttendClockLate, fmt.Sprintf("早退 %d 分钟(应退 %s),已记录签退", end-nowMinutes, row.WorkEnd) } return models.OaAttendClockNormal, "" } // oaAttendPrevOpen 跨天班次兜底:昨天签到未签退、且班次为跨天班次时, // 今天的签退落到昨天那条记录上,避免凌晨签退被记成新的一天。 func oaAttendPrevOpen(tid uint64, uid uint64, employeeID uint, date string) *models.OaAttendance { prevDate := nextOaDate(date, -1) if prevDate == "" { return nil } row := oaAttendFind(tid, prevDate, employeeID, uid) if row == nil || row.ClockInTime == nil || row.ClockOutTime != nil { return nil } start, end, ok := oaShiftWindow(row.WorkStart, row.WorkEnd) if !ok || end <= start || end <= 24*60 { return nil } return row } // oaAttendPunch 考勤打卡核心:写库并返回记录、提醒文案与是否重复打卡。 func oaAttendPunch(tid, uid uint64, employee *models.BackendEmployee, input oaAttendPunchInput) (*oaAttendPunchResult, int, string) { clockType := strings.ToLower(strings.TrimSpace(input.Type)) if clockType != "in" && clockType != "out" { return nil, 400, "打卡类型无效" } now := time.Now() date := now.Format("2006-01-02") employeeID := uint(0) name := strings.TrimSpace(input.FallbackName) account := "" orgID := uint64(0) orgName := "" position := "" if employee != nil { employeeID = employee.ID name = employee.Name account = employee.Account orgID = scEmployeeDepartmentID(*employee) orgName = oaAttendOrgName(tid, orgID) if employee.Position != nil { position = strings.TrimSpace(*employee.Position) } } if name == "" { name = "未知员工" } row := oaAttendFind(tid, date, employeeID, uid) if clockType == "out" && (row == nil || row.ClockInTime == nil) { if prev := oaAttendPrevOpen(tid, uid, employeeID, date); prev != nil { row = prev } } created := row == nil if created { row = &models.OaAttendance{ Tid: int(tid), Uid: uid, EmployeeID: employeeID, EmployeeName: name, EmployeeAccount: account, OrgID: orgID, OrgName: orgName, Position: position, AttendDate: date, MatchStatus: models.OaAttendMatchUnknown, Source: models.OaAttendSourceApp, } } else if row.EmployeeID == 0 && employeeID > 0 { // 早前未绑定员工档案时打的卡,补齐员工信息 row.EmployeeID = employeeID row.EmployeeName = name row.EmployeeAccount = account row.OrgID = orgID row.OrgName = orgName row.Position = position } // 排班快照:以打卡时的排班为准,后续调班不影响历史记录 if entry := oaAttendEntryOf(tid, employeeID, normalizeOaDate(row.AttendDate)); entry != nil { row.EntryID = entry.ID row.ShiftID = entry.ShiftID row.ShiftName = entry.ShiftName row.ShortName = entry.ShortName row.Color = entry.Color row.WorkStart = entry.WorkStart row.WorkEnd = entry.WorkEnd row.MatchStatus = models.OaAttendMatchOK } else if created { row.MatchStatus = models.OaAttendMatchMissing } warning := "" switch clockType { case "in": if row.ClockInTime != nil { return &oaAttendPunchResult{ View: oaAttendView(row), Warning: "今日已于 " + row.ClockInTime.Format("15:04") + " 签到,无需重复签到", Duplicated: true, Message: "今日已签到", }, 200, "" } row.ClockInTime = &now row.ClockInStatus, warning = oaAttendClockInStatus(row, now) row.ClockInNote = warning if input.Location != "" { row.Location = input.Location } case "out": if row.ClockOutTime != nil { return &oaAttendPunchResult{ View: oaAttendView(row), Warning: "今日已于 " + row.ClockOutTime.Format("15:04") + " 签退,无需重复签退", Duplicated: true, Message: "今日已签退", }, 200, "" } if row.ClockInTime == nil { warning = "今日尚未签到,本次签退已记录,请联系管理员核实" } row.ClockOutTime = &now status, msg := oaAttendClockOutStatus(row, now) row.ClockOutStatus = status notes := make([]string, 0, 2) if warning != "" { notes = append(notes, warning) } if msg != "" { notes = append(notes, msg) if warning == "" { warning = msg } } row.ClockOutNote = strings.Join(notes, ";") } if input.Remark != "" { row.Remark = input.Remark } fields := []string{ "uid", "employee_id", "employee_name", "employee_account", "org_id", "org_name", "position", "entry_id", "shift_id", "shift_name", "short_name", "color", "work_start", "work_end", "match_status", "clock_in_time", "clock_in_status", "clock_in_note", "clock_out_time", "clock_out_status", "clock_out_note", "location", "remark", "source", } if created { if _, err := models.Orm.Insert(row); err != nil { // 并发下唯一键冲突:按已打卡返回,避免前端误判失败 if exist := oaAttendFind(tid, date, employeeID, uid); exist != nil { return &oaAttendPunchResult{ View: oaAttendView(exist), Warning: "今日已打卡,请勿重复操作", Duplicated: true, Message: "今日已打卡", }, 200, "" } return nil, 500, "打卡失败,请稍后重试" } } else if _, err := models.Orm.Update(row, fields...); err != nil { return nil, 500, "打卡失败,请稍后重试" } message := "签到成功" if clockType == "out" { message = "签退成功" } return &oaAttendPunchResult{ View: oaAttendView(row), Warning: warning, Message: message, }, 200, "" } // --------------------------------------------------------------------------- // 统计 // --------------------------------------------------------------------------- // oaAttendStats 区间考勤统计:应出勤按排班明细统计,实出勤按已签到(含无排班打卡)。 // orgIDs 非空时按组织过滤;employeeID>0 时只统计该员工。 func oaAttendStats(tid uint64, startDate, endDate string, orgIDs []uint64, employeeID uint) map[string]interface{} { entryQS := models.Orm.QueryTable(new(models.OaScheduleEntry)). Filter("tid", tid). Filter("is_deleted", 0). Filter("schedule_date__gte", startDate). Filter("schedule_date__lte", endDate) if len(orgIDs) > 0 { entryQS = entryQS.Filter("org_id__in", orgIDs) } if employeeID > 0 { entryQS = entryQS.Filter("employee_id", employeeID) } scheduled, _ := entryQS.Count() recordQS := oaAttendQuery(tid). Filter("attend_date__gte", startDate). Filter("attend_date__lte", endDate) if len(orgIDs) > 0 { recordQS = recordQS.Filter("org_id__in", orgIDs) } if employeeID > 0 { recordQS = recordQS.Filter("employee_id", employeeID) } total, _ := recordQS.Count() baseCond := func() *orm.Condition { cond := orm.NewCondition(). And("tid", tid). And("attend_date__gte", startDate). And("attend_date__lte", endDate) if len(orgIDs) > 0 { cond = cond.And("org_id__in", orgIDs) } if employeeID > 0 { cond = cond.And("employee_id", employeeID) } return cond } countWith := func(cond *orm.Condition) int64 { n, _ := models.Orm.QueryTable(new(models.OaAttendance)).SetCond(cond).Count() return n } clocked := countWith(baseCond().And("clock_in_time__isnull", false)) late := countWith(baseCond().And("clock_in_status", models.OaAttendClockLate)) early := countWith(baseCond().And("clock_out_status", models.OaAttendClockLate)) noSchedule := countWith(baseCond().And("match_status", models.OaAttendMatchMissing)) matchedClockIn := countWith(baseCond(). And("clock_in_time__isnull", false). And("match_status", models.OaAttendMatchOK)) missing := scheduled - matchedClockIn if missing < 0 { missing = 0 } rate := 0 if scheduled > 0 { rate = int(matchedClockIn * 100 / scheduled) } return map[string]interface{}{ "scheduled": scheduled, "records": total, "clocked": clocked, "late": late, "early": early, "noschedule": noSchedule, "missing": missing, "attendance_rate": rate, } } // --------------------------------------------------------------------------- // 后台:考勤记录 // --------------------------------------------------------------------------- // RecordList 后台考勤记录列表。 // GET /backend/oa/attendance/records?page=&pageSize=&start_date=&end_date= // // &keyword=&org_id=&include_child=1&employee_id=&status=&match_status= func (c *BackendOaAttendanceController) RecordList() { tid, _, ok := c.atOperator() if !ok { return } page, _ := c.GetInt("page", 1) pageSize, _ := c.GetInt("pageSize", 20) if page < 1 { page = 1 } if pageSize < 1 || pageSize > oaAttendMaxPageSize { pageSize = 20 } startDate := strings.TrimSpace(c.GetString("start_date")) endDate := strings.TrimSpace(c.GetString("end_date")) if startDate == "" || endDate == "" { now := time.Now() start := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) startDate = start.Format("2006-01-02") endDate = now.Format("2006-01-02") } dates, msg := scDateSpan(startDate, endDate) if msg != "" { c.atFail(400, 400, msg) return } if len(dates) > oaAttendMaxRangeDays { c.atFail(400, 400, fmt.Sprintf("单次最多查询 %d 天", oaAttendMaxRangeDays)) return } orgID := uint64(0) if raw := strings.TrimSpace(c.GetString("org_id")); raw != "" { if parsed, err := strconvParseUint(raw); err == nil { orgID = parsed } } includeChild := c.GetString("include_child") != "0" orgIDs := oaAttendOrgIDs(tid, orgID, includeChild) keyword := strings.TrimSpace(c.GetString("keyword")) employeeID := uint(0) if raw := strings.TrimSpace(c.GetString("employee_id")); raw != "" { if parsed, err := strconv.ParseUint(raw, 10, 64); err == nil { employeeID = uint(parsed) } } // uid:未绑定员工档案的打卡按登录身份归属,汇总行展开明细时用 uid := uint64(0) if raw := strings.TrimSpace(c.GetString("uid")); raw != "" { if parsed, err := strconv.ParseUint(raw, 10, 64); err == nil { uid = parsed } } status := strings.TrimSpace(c.GetString("status")) matchStatus := strings.TrimSpace(c.GetString("match_status")) cond := orm.NewCondition(). And("tid", tid). And("attend_date__gte", startDate). And("attend_date__lte", endDate) if len(orgIDs) > 0 { cond = cond.And("org_id__in", orgIDs) } if employeeID > 0 { cond = cond.And("employee_id", employeeID) } if uid > 0 { cond = cond.And("uid", uid) } if keyword != "" { cond = cond.AndCond(orm.NewCondition(). Or("employee_name__icontains", keyword). Or("employee_account__icontains", keyword)) } if matchStatus == "1" || matchStatus == "2" { value, _ := strconv.Atoi(matchStatus) cond = cond.And("match_status", int8(value)) } switch status { case "normal": cond = cond.And("clock_in_status", models.OaAttendClockNormal) case "late": cond = cond.And("clock_in_status", models.OaAttendClockLate) case "early": cond = cond.And("clock_out_status", models.OaAttendClockLate) case "noschedule": cond = cond.And("match_status", models.OaAttendMatchMissing) case "missing": cond = cond.And("clock_in_time__isnull", true) case "offtime": cond = cond.AndCond(orm.NewCondition(). Or("clock_in_status", models.OaAttendClockOffTime). Or("clock_out_status", models.OaAttendClockOffTime)) } qs := models.Orm.QueryTable(new(models.OaAttendance)).SetCond(cond) total, _ := qs.Count() var rows []models.OaAttendance _, err := qs.OrderBy("-attend_date", "employee_name", "id"). Limit(pageSize).Offset((page - 1) * pageSize).All(&rows) if err != nil && err != orm.ErrNoRows { c.atFail(500, 500, "查询考勤记录失败: "+err.Error()) return } list := make([]oaAttendanceView, 0, len(rows)) for i := range rows { list = append(list, oaAttendView(&rows[i])) } c.atOK(map[string]interface{}{ "list": list, "total": total, "start_date": startDate, "end_date": endDate, "stats": oaAttendStats(tid, startDate, endDate, orgIDs, employeeID), }) } // MissingList 缺卡名单:指定日期有排班但未签到的人。 // GET /backend/oa/attendance/missing?date=&org_id=&include_child=1&keyword= func (c *BackendOaAttendanceController) MissingList() { tid, _, ok := c.atOperator() if !ok { return } date := strings.TrimSpace(c.GetString("date")) if !isValidOaDate(date) { date = time.Now().Format("2006-01-02") } orgID := uint64(0) if raw := strings.TrimSpace(c.GetString("org_id")); raw != "" { if parsed, err := strconvParseUint(raw); err == nil { orgID = parsed } } orgIDs := oaAttendOrgIDs(tid, orgID, c.GetString("include_child") != "0") keyword := strings.TrimSpace(c.GetString("keyword")) entryQS := models.Orm.QueryTable(new(models.OaScheduleEntry)). Filter("tid", tid). Filter("is_deleted", 0). Filter("schedule_date", date) if len(orgIDs) > 0 { entryQS = entryQS.Filter("org_id__in", orgIDs) } if keyword != "" { entryQS = entryQS.Filter("employee_name__icontains", keyword) } var entries []models.OaScheduleEntry _, err := entryQS.OrderBy("org_id", "employee_name").Limit(1000).All(&entries) if err != nil && err != orm.ErrNoRows { c.atFail(500, 500, "查询排班失败: "+err.Error()) return } clocked := map[uint]bool{} var records []models.OaAttendance recordQS := oaAttendQuery(tid).Filter("attend_date", date).Filter("clock_in_time__isnull", false) if len(orgIDs) > 0 { recordQS = recordQS.Filter("org_id__in", orgIDs) } _, _ = recordQS.All(&records) for i := range records { clocked[records[i].EmployeeID] = true } list := make([]map[string]interface{}, 0, len(entries)) for i := range entries { entry := entries[i] if clocked[entry.EmployeeID] { continue } list = append(list, map[string]interface{}{ "employee_id": entry.EmployeeID, "employee_name": entry.EmployeeName, "employee_account": entry.EmployeeAccount, "org_id": entry.OrgID, "org_name": entry.OrgName, "position": entry.Position, "date": normalizeOaDate(entry.ScheduleDate), "shift_id": entry.ShiftID, "shift_name": entry.ShiftName, "color": entry.Color, "work_start": entry.WorkStart, "work_end": entry.WorkEnd, "shift_text": oaAttendShiftText(entry.WorkStart, entry.WorkEnd), }) } c.atOK(map[string]interface{}{ "date": date, "list": list, "total": len(list), }) } // RecordRemark 管理员备注 / 异常说明。 // POST /backend/oa/attendance/remark/:id body: {"remark": "..."} func (c *BackendOaAttendanceController) RecordRemark() { tid, _, ok := c.atOperator() if !ok { return } id, err := strconv.ParseUint(strings.TrimSpace(c.Ctx.Input.Param(":id")), 10, 64) if err != nil || id == 0 { c.atFail(400, 400, "无效的记录ID") return } var payload struct { Remark string `json:"remark"` } if len(c.Ctx.Input.RequestBody) > 0 { if err := json.Unmarshal(c.Ctx.Input.RequestBody, &payload); err != nil { c.atFail(400, 400, "参数错误") return } } remark := strings.TrimSpace(payload.Remark) if len([]rune(remark)) > 255 { c.atFail(400, 400, "备注不能超过 255 个字符") return } var row models.OaAttendance if err := oaAttendQuery(tid).Filter("id", id).One(&row); err != nil { c.atFail(404, 404, "考勤记录不存在") return } row.Remark = remark if _, err := models.Orm.Update(&row, "remark"); err != nil { c.atFail(500, 500, "保存备注失败: "+err.Error()) return } c.atOK(oaAttendView(&row)) }