473 lines
14 KiB
Go
473 lines
14 KiB
Go
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),
|
||
})
|
||
}
|