增加ai办公,考勤管理

This commit is contained in:
hero920103 committed 2026-11-14 18:15:55 +08:00
1 parent eb9b7beff5
commit 31ae07175d
46 files changed
+9250 -296

No files matched your search

+472
View File
@@ -0,0 +1,472 @@
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),
})
}