Files
yunzerwebsiteallinone/go/controllers/app_oa_attendance.go
T

473 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),
})
}