增加ai办公,考勤管理
This commit is contained in:
1 parent
eb9b7beff5
commit
31ae07175d
46 files changed
+9250
-296
No files matched your search
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"server/pkg/jwtutil"
|
||||
"server/services"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
@@ -51,6 +52,8 @@ type chatSendPayload struct {
|
||||
McpServerIDs []uint64 `json:"mcp_server_ids"`
|
||||
// Images 用户上传的图片(base64 dataURL),仅多模态模型可用
|
||||
Images []string `json:"images"`
|
||||
// SystemPrompt 本轮自定义系统提示词(AI 广场的场景对话使用),为空时回落租户预设
|
||||
SystemPrompt string `json:"system_prompt"`
|
||||
}
|
||||
|
||||
// mcpEnabled 解析本轮是否启用 MCP(未传默认开启)
|
||||
@@ -542,7 +545,8 @@ func resolveOrCreateSession(c *BackendAiChatController, claims *jwtutil.Claims,
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// resolveProvider 解析 AI 接入配置
|
||||
// resolveProvider 解析 AI 接入配置。
|
||||
// 优先级:租户级配置(user_id=0,由 OA 设置统一维护)→ 个人配置(历史数据)。
|
||||
func resolveProvider(claims *jwtutil.Claims, p *chatSendPayload, session *models.BackendAiChatSession) (models.BackendAiProvider, error) {
|
||||
providerID := p.ProviderID
|
||||
if providerID == 0 {
|
||||
@@ -555,24 +559,33 @@ func resolveProvider(claims *jwtutil.Claims, p *chatSendPayload, session *models
|
||||
if err := models.Orm.Read(&provider); err != nil {
|
||||
return provider, fmt.Errorf("指定的AI接入配置不存在,请先在设置中配置")
|
||||
}
|
||||
if provider.TenantID != fmt.Sprintf("%d", claims.TenantId) || provider.UserID != uint64(claims.UserID) {
|
||||
if provider.TenantID != fmt.Sprintf("%d", claims.TenantId) {
|
||||
return provider, fmt.Errorf("无权使用该配置")
|
||||
}
|
||||
// user_id=0 是租户级配置,全员可用;其余只允许本人使用
|
||||
if provider.UserID != 0 && provider.UserID != uint64(claims.UserID) {
|
||||
return provider, fmt.Errorf("无权使用该配置")
|
||||
}
|
||||
if provider.Status != 1 {
|
||||
return provider, fmt.Errorf("该AI接入配置已被禁用")
|
||||
}
|
||||
} else {
|
||||
tid := fmt.Sprintf("%d", claims.TenantId)
|
||||
cond := orm.NewCondition().
|
||||
And("tenant_id", tid).
|
||||
And("status", 1).
|
||||
And("delete_time__isnull", true).
|
||||
AndCond(orm.NewCondition().
|
||||
Or("user_id", 0).
|
||||
Or("user_id", uint64(claims.UserID)))
|
||||
var providers []models.BackendAiProvider
|
||||
_, err := models.Orm.QueryTable(new(models.BackendAiProvider)).
|
||||
Filter("tenant_id", fmt.Sprintf("%d", claims.TenantId)).
|
||||
Filter("user_id", uint64(claims.UserID)).
|
||||
Filter("status", 1).
|
||||
Filter("delete_time__isnull", true).
|
||||
OrderBy("-id").
|
||||
SetCond(cond).
|
||||
OrderBy("user_id", "-is_default", "-id").
|
||||
Limit(1).
|
||||
All(&providers)
|
||||
if err != nil || len(providers) == 0 {
|
||||
return provider, fmt.Errorf("尚未配置AI接入,请先在设置中配置OpenAI或Anthropic接入")
|
||||
return provider, fmt.Errorf("尚未配置AI接入,请先联系管理员在「基础设置 → AI 设置」中填写接口地址与 API Key")
|
||||
}
|
||||
provider = providers[0]
|
||||
}
|
||||
@@ -610,6 +623,17 @@ func loadSystemPrompt(claims *jwtutil.Claims) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// chatSystemPrompt 优先使用本轮传入的系统提示词(AI 广场的场景对话),否则回落租户预设。
|
||||
func chatSystemPrompt(claims *jwtutil.Claims, p *chatSendPayload) string {
|
||||
if v := strings.TrimSpace(p.SystemPrompt); v != "" {
|
||||
if len([]rune(v)) > 4000 {
|
||||
v = string([]rune(v)[:4000])
|
||||
}
|
||||
return v
|
||||
}
|
||||
return loadSystemPrompt(claims)
|
||||
}
|
||||
|
||||
// loadHistory 加载会话历史(最近20条,正序)
|
||||
func loadHistory(sessionID uint64) []openaiMessage {
|
||||
var history []models.BackendAiChatMessage
|
||||
@@ -671,7 +695,7 @@ func (c *BackendAiChatController) Send() {
|
||||
c.chatJsonErr(400, 400, err.Error())
|
||||
return
|
||||
}
|
||||
systemPrompt := loadSystemPrompt(claims)
|
||||
systemPrompt := chatSystemPrompt(claims, &p)
|
||||
|
||||
messages := loadHistory(session.ID)
|
||||
messages = append(messages, openaiMessage{Role: "user", Content: p.Content, Images: p.Images})
|
||||
@@ -787,7 +811,7 @@ func (c *BackendAiChatController) SendStream() {
|
||||
c.chatJsonErr(400, 400, err.Error())
|
||||
return
|
||||
}
|
||||
systemPrompt := loadSystemPrompt(claims)
|
||||
systemPrompt := chatSystemPrompt(claims, &p)
|
||||
|
||||
messages := loadHistory(session.ID)
|
||||
messages = append(messages, openaiMessage{Role: "user", Content: p.Content, Images: p.Images})
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"server/pkg/identity"
|
||||
"server/pkg/jwtutil"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
@@ -46,13 +47,22 @@ type aiProviderPayload struct {
|
||||
IsDefault int8 `json:"is_default"`
|
||||
Status int8 `json:"status"`
|
||||
Remark string `json:"remark"`
|
||||
// Scope "tenant" 表示租户级配置(user_id=0,全员共用,由「基础设置 → AI 设置」维护);
|
||||
// 其它值/空表示个人配置,仅创建者可用。
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
// clearTenantDefault 取消租户内所有默认配置
|
||||
func clearTenantDefault(tenantID string) {
|
||||
// aiTenantScope 本次请求是否在维护租户级配置
|
||||
func aiTenantScope(scope string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(scope), "tenant")
|
||||
}
|
||||
|
||||
// clearTenantDefault 取消同一归属范围内(租户级 / 同一用户)的其他默认配置
|
||||
func clearTenantDefault(tenantID string, userID uint64) {
|
||||
var defaults []models.BackendAiProvider
|
||||
_, _ = models.Orm.QueryTable(new(models.BackendAiProvider)).
|
||||
Filter("tenant_id", tenantID).
|
||||
Filter("user_id", userID).
|
||||
Filter("is_default", 1).
|
||||
All(&defaults)
|
||||
for _, d := range defaults {
|
||||
@@ -75,7 +85,9 @@ func parseProviderModels(m string) []string {
|
||||
return list
|
||||
}
|
||||
|
||||
// List GET /backend/ai/provider/list
|
||||
// List GET /backend/ai/provider/list?scope=tenant
|
||||
// scope=tenant 只返回租户级配置(user_id=0);不传时返回「租户级 + 当前用户」,
|
||||
// 供 AI 广场选择可用接入。
|
||||
func (c *BackendAiProviderController) List() {
|
||||
claims, err := c.aiClaims()
|
||||
if err != nil {
|
||||
@@ -84,21 +96,34 @@ func (c *BackendAiProviderController) List() {
|
||||
return
|
||||
}
|
||||
|
||||
cond := orm.NewCondition().
|
||||
And("tenant_id", fmt.Sprintf("%d", claims.TenantId)).
|
||||
And("delete_time__isnull", true)
|
||||
if aiTenantScope(c.GetString("scope")) {
|
||||
cond = cond.And("user_id", 0)
|
||||
} else {
|
||||
cond = cond.AndCond(orm.NewCondition().
|
||||
Or("user_id", 0).
|
||||
Or("user_id", uint64(claims.UserID)))
|
||||
}
|
||||
|
||||
var list []models.BackendAiProvider
|
||||
_, err = models.Orm.QueryTable(new(models.BackendAiProvider)).
|
||||
Filter("tenant_id", fmt.Sprintf("%d", claims.TenantId)).
|
||||
Filter("user_id", uint64(claims.UserID)).
|
||||
Filter("delete_time__isnull", true).
|
||||
OrderBy("-id").
|
||||
SetCond(cond).
|
||||
OrderBy("user_id", "-id").
|
||||
All(&list)
|
||||
if err != nil {
|
||||
c.aiJsonErr(500, 500, "查询失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 解析模型列表(不脱敏api_key,前端需要完整api_key进行测试)
|
||||
// 解析模型列表。个人配置不回传脱敏 Key(本人可见);
|
||||
// 租户级配置(user_id=0)全租户共用,Key 必须脱敏,测试走 test 接口的 id 模式。
|
||||
for i := range list {
|
||||
list[i].ModelsList = parseProviderModels(list[i].Models)
|
||||
if list[i].UserID == 0 {
|
||||
list[i].ApiKey = maskTenantAiKey(list[i].ApiKey)
|
||||
}
|
||||
}
|
||||
|
||||
c.aiOk(map[string]interface{}{"list": list})
|
||||
@@ -144,14 +169,20 @@ func (c *BackendAiProviderController) Create() {
|
||||
// 模型数组序列化为JSON
|
||||
modelsJSON, _ := json.Marshal(p.Models)
|
||||
|
||||
// 如果设为默认,先取消租户内其他默认
|
||||
// 租户级配置(user_id=0)由 OA 设置统一维护,全员可用
|
||||
ownerID := uint64(claims.UserID)
|
||||
if aiTenantScope(p.Scope) {
|
||||
ownerID = 0
|
||||
}
|
||||
|
||||
// 如果设为默认,先取消同一归属范围内的其他默认
|
||||
if p.IsDefault == 1 {
|
||||
clearTenantDefault(fmt.Sprintf("%d", claims.TenantId))
|
||||
clearTenantDefault(fmt.Sprintf("%d", claims.TenantId), ownerID)
|
||||
}
|
||||
|
||||
provider := models.BackendAiProvider{
|
||||
TenantID: fmt.Sprintf("%d", claims.TenantId),
|
||||
UserID: uint64(claims.UserID),
|
||||
UserID: ownerID,
|
||||
ProviderType: p.ProviderType,
|
||||
Name: strings.TrimSpace(p.Name),
|
||||
ApiBase: strings.TrimSpace(p.ApiBase),
|
||||
@@ -199,6 +230,11 @@ func (c *BackendAiProviderController) Update() {
|
||||
c.aiJsonErr(403, 403, "无权操作")
|
||||
return
|
||||
}
|
||||
// 租户级配置(user_id=0)允许本租户管理员在 AI 设置中维护,个人配置仅本人可改
|
||||
if provider.UserID != 0 && provider.UserID != uint64(claims.UserID) {
|
||||
c.aiJsonErr(403, 403, "无权操作")
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(c.Ctx.Request.Body)
|
||||
if err != nil {
|
||||
@@ -211,9 +247,9 @@ func (c *BackendAiProviderController) Update() {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果设为默认,先取消租户内其他默认
|
||||
// 如果设为默认,先取消同一归属范围内的其他默认
|
||||
if p.IsDefault == 1 && provider.IsDefault != 1 {
|
||||
clearTenantDefault(fmt.Sprintf("%d", claims.TenantId))
|
||||
clearTenantDefault(fmt.Sprintf("%d", claims.TenantId), provider.UserID)
|
||||
}
|
||||
|
||||
provider.Name = strings.TrimSpace(p.Name)
|
||||
@@ -268,6 +304,10 @@ func (c *BackendAiProviderController) Delete() {
|
||||
c.aiJsonErr(403, 403, "无权操作")
|
||||
return
|
||||
}
|
||||
if provider.UserID != 0 && provider.UserID != uint64(claims.UserID) {
|
||||
c.aiJsonErr(403, 403, "无权操作")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
provider.DeleteTime = &now
|
||||
@@ -281,6 +321,8 @@ func (c *BackendAiProviderController) Delete() {
|
||||
}
|
||||
|
||||
type aiTestPayload struct {
|
||||
// ID 已保存的配置ID:不传 api_key(或传脱敏值)时用库里的密钥测试
|
||||
ID uint64 `json:"id"`
|
||||
ProviderType string `json:"provider_type"`
|
||||
ApiBase string `json:"api_base"`
|
||||
ApiKey string `json:"api_key"`
|
||||
@@ -293,10 +335,26 @@ type modelTestResult struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// maskTenantAiKey 脱敏租户级配置的密钥(保留前后各 4 位)。
|
||||
// 注意:结果包含 ****,Update 遇到含 **** 的 key 会跳过更新,不会把脱敏值写回库。
|
||||
func maskTenantAiKey(key string) string {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
if len(key) <= 8 {
|
||||
return "****"
|
||||
}
|
||||
return key[:4] + "****" + key[len(key)-4:]
|
||||
}
|
||||
|
||||
// Test POST /backend/ai/provider/test
|
||||
// 批量测试模型连通性
|
||||
// 批量测试模型连通性。支持两种模式:
|
||||
// 1. 传 api_base + api_key:直接测试(新增/修改未保存的表单);
|
||||
// 2. 传 id(api_key 留空或为脱敏值):用已保存配置的密钥测试。
|
||||
func (c *BackendAiProviderController) Test() {
|
||||
if _, err := c.aiClaims(); err != nil {
|
||||
claims, err := c.aiClaims()
|
||||
if err != nil {
|
||||
code := identity.Status(err)
|
||||
c.aiJsonErr(code, code, err.Error())
|
||||
return
|
||||
@@ -313,7 +371,31 @@ func (c *BackendAiProviderController) Test() {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(p.ApiBase) == "" || strings.TrimSpace(p.ApiKey) == "" {
|
||||
apiKey := strings.TrimSpace(p.ApiKey)
|
||||
if p.ID > 0 && (apiKey == "" || strings.Contains(apiKey, "****")) {
|
||||
stored := models.BackendAiProvider{ID: p.ID}
|
||||
if err := models.Orm.Read(&stored); err != nil {
|
||||
c.aiJsonErr(404, 404, "配置不存在")
|
||||
return
|
||||
}
|
||||
if stored.TenantID != fmt.Sprintf("%d", claims.TenantId) {
|
||||
c.aiJsonErr(403, 403, "无权操作")
|
||||
return
|
||||
}
|
||||
if stored.UserID != 0 && stored.UserID != uint64(claims.UserID) {
|
||||
c.aiJsonErr(403, 403, "无权操作")
|
||||
return
|
||||
}
|
||||
apiKey = stored.ApiKey
|
||||
if strings.TrimSpace(p.ApiBase) == "" {
|
||||
p.ApiBase = stored.ApiBase
|
||||
}
|
||||
if strings.TrimSpace(p.ProviderType) == "" {
|
||||
p.ProviderType = stored.ProviderType
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(p.ApiBase) == "" || apiKey == "" {
|
||||
c.aiJsonErr(400, 400, "接口地址和API Key不能为空")
|
||||
return
|
||||
}
|
||||
@@ -326,7 +408,7 @@ func (c *BackendAiProviderController) Test() {
|
||||
provider := models.BackendAiProvider{
|
||||
ProviderType: p.ProviderType,
|
||||
ApiBase: strings.TrimSpace(p.ApiBase),
|
||||
ApiKey: strings.TrimSpace(p.ApiKey),
|
||||
ApiKey: apiKey,
|
||||
}
|
||||
testMessages := []openaiMessage{{Role: "user", Content: "hi"}}
|
||||
|
||||
|
||||
@@ -0,0 +1,919 @@
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,719 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
)
|
||||
|
||||
// 考勤汇总:按月 / 季 / 年统计,覆盖全部在职员工(含无打卡记录的人)。
|
||||
//
|
||||
// 口径:
|
||||
// - 应出勤 = 区间内排班明细条数(一人一天一条);
|
||||
// - 已打卡 = 区间内有签到记录的考勤条数;
|
||||
// - 缺卡 = 应出勤 - 匹配排班且已签到的条数(负值归零);
|
||||
// - 出勤率 = 匹配排班且已签到 / 应出勤。
|
||||
//
|
||||
// 周期卡片的取数:
|
||||
// - 以锚点日期所在年份为准,一次性查出「按月的排班人数、按月的打卡情况」,
|
||||
// 再在内存里累加成季度卡与年度卡,避免 17 次独立查询。
|
||||
|
||||
// oaAttendCard 一个周期的考勤卡(月 / 季 / 年)。
|
||||
type oaAttendCard struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Period string `json:"period"`
|
||||
Anchor string `json:"anchor"`
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
Future bool `json:"future"`
|
||||
Current bool `json:"current"`
|
||||
Scheduled int64 `json:"scheduled"`
|
||||
Clocked int64 `json:"clocked"`
|
||||
Matched int64 `json:"matched"`
|
||||
Late int64 `json:"late"`
|
||||
Early int64 `json:"early"`
|
||||
Missing int64 `json:"missing"`
|
||||
NoSchedule int64 `json:"noschedule"`
|
||||
AttendanceRate int `json:"attendance_rate"`
|
||||
}
|
||||
|
||||
// oaAttendSummaryRow 汇总表的一行:一名员工在一个周期内的考勤卡。
|
||||
type oaAttendSummaryRow struct {
|
||||
Key string `json:"key"`
|
||||
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"`
|
||||
Bound bool `json:"bound"`
|
||||
ScheduledDays int64 `json:"scheduled_days"`
|
||||
ClockedDays int64 `json:"clocked_days"`
|
||||
LateCount int64 `json:"late_count"`
|
||||
EarlyCount int64 `json:"early_count"`
|
||||
MissingDays int64 `json:"missing_days"`
|
||||
NoScheduleCount int64 `json:"noschedule_count"`
|
||||
OffTimeCount int64 `json:"offtime_count"`
|
||||
AttendanceRate int `json:"attendance_rate"`
|
||||
}
|
||||
|
||||
// oaAttendCountByMonth 按月排班条数。
|
||||
type oaAttendCountByMonth struct {
|
||||
Month int64 `orm:"column(m)"`
|
||||
Cnt int64 `orm:"column(cnt)"`
|
||||
}
|
||||
|
||||
// oaAttendRecordByMonth 按月打卡统计。
|
||||
type oaAttendRecordByMonth struct {
|
||||
Month int64 `orm:"column(m)"`
|
||||
Clocked int64 `orm:"column(clocked)"`
|
||||
Matched int64 `orm:"column(matched)"`
|
||||
Late int64 `orm:"column(late)"`
|
||||
Early int64 `orm:"column(early)"`
|
||||
NoShift int64 `orm:"column(noschedule)"`
|
||||
}
|
||||
|
||||
// oaAttendEmpCount 按员工统计的排班条数。
|
||||
type oaAttendEmpCount struct {
|
||||
EmployeeID uint `orm:"column(employee_id)"`
|
||||
Cnt int64 `orm:"column(cnt)"`
|
||||
}
|
||||
|
||||
// oaAttendRowAgg 按员工聚合的打卡统计。
|
||||
type oaAttendRowAgg struct {
|
||||
EmployeeID uint `orm:"column(employee_id)"`
|
||||
Uid uint64 `orm:"column(uid)"`
|
||||
EmployeeName string `orm:"column(employee_name)"`
|
||||
EmployeeAccount string `orm:"column(employee_account)"`
|
||||
OrgID uint64 `orm:"column(org_id)"`
|
||||
OrgName string `orm:"column(org_name)"`
|
||||
Clocked int64 `orm:"column(clocked)"`
|
||||
Matched int64 `orm:"column(matched)"`
|
||||
Late int64 `orm:"column(late)"`
|
||||
Early int64 `orm:"column(early)"`
|
||||
NoShift int64 `orm:"column(noschedule)"`
|
||||
OffTime int64 `orm:"column(offtime)"`
|
||||
}
|
||||
|
||||
// oaAttendAgg 汇总用的累加器。
|
||||
type oaAttendAgg struct {
|
||||
scheduled int64
|
||||
clocked int64
|
||||
matched int64
|
||||
late int64
|
||||
early int64
|
||||
noShift int64
|
||||
offTime int64
|
||||
}
|
||||
|
||||
// oaAttendPeriodRange 把锚点日期换算成统计区间;
|
||||
// period = month / quarter / year,返回的 end 未做「不超过今天」裁剪。
|
||||
func oaAttendPeriodRange(period, anchor string) (string, string, string, bool) {
|
||||
parsed, ok := scParseDate(anchor)
|
||||
if !ok {
|
||||
return "", "", "", false
|
||||
}
|
||||
period = strings.ToLower(strings.TrimSpace(period))
|
||||
var start, end time.Time
|
||||
var label string
|
||||
switch period {
|
||||
case "year":
|
||||
start = time.Date(parsed.Year(), 1, 1, 0, 0, 0, 0, parsed.Location())
|
||||
end = time.Date(parsed.Year(), 12, 31, 0, 0, 0, 0, parsed.Location())
|
||||
label = fmt.Sprintf("%d 年", parsed.Year())
|
||||
case "quarter":
|
||||
quarter := (int(parsed.Month()) - 1) / 3
|
||||
start = time.Date(parsed.Year(), time.Month(quarter*3+1), 1, 0, 0, 0, 0, parsed.Location())
|
||||
end = start.AddDate(0, 3, -1)
|
||||
label = fmt.Sprintf("%d 年第 %d 季度", parsed.Year(), quarter+1)
|
||||
default:
|
||||
period = "month"
|
||||
start = time.Date(parsed.Year(), parsed.Month(), 1, 0, 0, 0, 0, parsed.Location())
|
||||
end = start.AddDate(0, 1, -1)
|
||||
label = fmt.Sprintf("%d 年 %d 月", parsed.Year(), int(parsed.Month()))
|
||||
}
|
||||
return start.Format("2006-01-02"), end.Format("2006-01-02"), label, true
|
||||
}
|
||||
|
||||
// oaAttendCapEnd 统计区间不跨过今天,避免未来排班被算成缺卡。
|
||||
func oaAttendCapEnd(end string) string {
|
||||
today := time.Now().Format("2006-01-02")
|
||||
if end > today {
|
||||
return today
|
||||
}
|
||||
return end
|
||||
}
|
||||
|
||||
// oaAttendClampRange 把区间末尾裁到今天;整段都在未来时保留原区间(展示用,查询自然为空)。
|
||||
func oaAttendClampRange(start, end string) string {
|
||||
capped := oaAttendCapEnd(end)
|
||||
if capped < start {
|
||||
return end
|
||||
}
|
||||
return capped
|
||||
}
|
||||
|
||||
// oaAttendFillCard 依据累计值补齐缺卡与出勤率。
|
||||
func oaAttendFillCard(card *oaAttendCard) {
|
||||
card.Missing = card.Scheduled - card.Matched
|
||||
if card.Missing < 0 {
|
||||
card.Missing = 0
|
||||
}
|
||||
if card.Scheduled > 0 {
|
||||
card.AttendanceRate = int(card.Matched * 100 / card.Scheduled)
|
||||
} else {
|
||||
card.AttendanceRate = 0
|
||||
}
|
||||
}
|
||||
|
||||
// oaAttendOrgSQL 生成组织过滤的 SQL 片段与参数。
|
||||
func oaAttendOrgSQL(orgIDs []uint64) (string, []interface{}) {
|
||||
if len(orgIDs) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
placeholders := make([]string, 0, len(orgIDs))
|
||||
args := make([]interface{}, 0, len(orgIDs))
|
||||
for _, id := range orgIDs {
|
||||
placeholders = append(placeholders, "?")
|
||||
args = append(args, id)
|
||||
}
|
||||
return " AND org_id IN (" + strings.Join(placeholders, ",") + ")", args
|
||||
}
|
||||
|
||||
// oaAttendMonthCards 生成锚点年份 1-12 月的考勤卡。
|
||||
func oaAttendMonthCards(tid uint64, year int, orgIDs []uint64) []oaAttendCard {
|
||||
yearStart := fmt.Sprintf("%d-01-01", year)
|
||||
yearEnd := oaAttendCapEnd(fmt.Sprintf("%d-12-31", year))
|
||||
|
||||
entryFilter, entryArgs := oaAttendOrgSQL(orgIDs)
|
||||
recordFilter, recordArgs := oaAttendOrgSQL(orgIDs)
|
||||
|
||||
entryCount := map[int64]int64{}
|
||||
recordAgg := map[int64]oaAttendRecordByMonth{}
|
||||
|
||||
// 整年都在未来:不查库,全部为 0
|
||||
if yearEnd >= yearStart {
|
||||
var entryRows []oaAttendCountByMonth
|
||||
entrySQL := "SELECT MONTH(schedule_date) AS m, COUNT(*) AS cnt FROM yz_backend_oa_schedule_entry" +
|
||||
" WHERE tid = ? AND is_deleted = 0 AND schedule_date >= ? AND schedule_date <= ?" + entryFilter +
|
||||
" GROUP BY MONTH(schedule_date)"
|
||||
entryQueryArgs := append([]interface{}{tid, yearStart, yearEnd}, entryArgs...)
|
||||
if _, err := models.Orm.Raw(entrySQL, entryQueryArgs...).QueryRows(&entryRows); err == nil {
|
||||
for _, row := range entryRows {
|
||||
entryCount[row.Month] = row.Cnt
|
||||
}
|
||||
}
|
||||
|
||||
var recordRows []oaAttendRecordByMonth
|
||||
recordSQL := "SELECT MONTH(attend_date) AS m," +
|
||||
" CAST(SUM(clock_in_time IS NOT NULL) AS SIGNED) AS clocked," +
|
||||
" CAST(SUM(clock_in_time IS NOT NULL AND match_status = 1) AS SIGNED) AS matched," +
|
||||
" CAST(SUM(clock_in_status = 2) AS SIGNED) AS late," +
|
||||
" CAST(SUM(clock_out_status = 2) AS SIGNED) AS early," +
|
||||
" CAST(SUM(match_status = 2) AS SIGNED) AS noschedule" +
|
||||
" FROM yz_backend_oa_attendance" +
|
||||
" WHERE tid = ? AND attend_date >= ? AND attend_date <= ?" + recordFilter +
|
||||
" GROUP BY MONTH(attend_date)"
|
||||
recordQueryArgs := append([]interface{}{tid, yearStart, yearEnd}, recordArgs...)
|
||||
if _, err := models.Orm.Raw(recordSQL, recordQueryArgs...).QueryRows(&recordRows); err == nil {
|
||||
for _, row := range recordRows {
|
||||
recordAgg[row.Month] = row
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
today := now.Format("2006-01-02")
|
||||
cards := make([]oaAttendCard, 0, 12)
|
||||
for month := 1; month <= 12; month++ {
|
||||
start := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, now.Location())
|
||||
end := start.AddDate(0, 1, -1)
|
||||
startDate := start.Format("2006-01-02")
|
||||
endDate := end.Format("2006-01-02")
|
||||
record := recordAgg[int64(month)]
|
||||
card := oaAttendCard{
|
||||
Key: fmt.Sprintf("%d-%02d", year, month),
|
||||
Label: fmt.Sprintf("%d 月", month),
|
||||
Period: "month",
|
||||
Anchor: startDate,
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
Future: startDate > today,
|
||||
Current: today >= startDate && today <= endDate,
|
||||
Scheduled: entryCount[int64(month)],
|
||||
Clocked: record.Clocked,
|
||||
Matched: record.Matched,
|
||||
Late: record.Late,
|
||||
Early: record.Early,
|
||||
NoSchedule: record.NoShift,
|
||||
}
|
||||
oaAttendFillCard(&card)
|
||||
cards = append(cards, card)
|
||||
}
|
||||
return cards
|
||||
}
|
||||
|
||||
// oaAttendAggregateCards 用月度卡累加出 4 个季度卡与年度卡。
|
||||
func oaAttendAggregateCards(months []oaAttendCard, year int) ([]oaAttendCard, oaAttendCard) {
|
||||
quarters := make([]oaAttendCard, 0, 4)
|
||||
yearCard := oaAttendCard{
|
||||
Key: fmt.Sprintf("%d", year),
|
||||
Label: fmt.Sprintf("%d 年", year),
|
||||
Period: "year",
|
||||
Anchor: fmt.Sprintf("%d-01-01", year),
|
||||
StartDate: fmt.Sprintf("%d-01-01", year),
|
||||
EndDate: fmt.Sprintf("%d-12-31", year),
|
||||
}
|
||||
today := time.Now().Format("2006-01-02")
|
||||
yearCard.Current = year == time.Now().Year()
|
||||
yearCard.Future = yearCard.StartDate > today
|
||||
|
||||
for index := 0; index < 4; index++ {
|
||||
start := index * 3
|
||||
if start+2 >= len(months) {
|
||||
break
|
||||
}
|
||||
first := months[start]
|
||||
last := months[start+2]
|
||||
quarter := oaAttendCard{
|
||||
Key: fmt.Sprintf("%d-Q%d", year, index+1),
|
||||
Label: fmt.Sprintf("%d 季度", index+1),
|
||||
Period: "quarter",
|
||||
Anchor: first.StartDate,
|
||||
StartDate: first.StartDate,
|
||||
EndDate: last.EndDate,
|
||||
Future: first.StartDate > today,
|
||||
Current: today >= first.StartDate && today <= last.EndDate,
|
||||
}
|
||||
for i := start; i < start+3; i++ {
|
||||
quarter.Scheduled += months[i].Scheduled
|
||||
quarter.Clocked += months[i].Clocked
|
||||
quarter.Matched += months[i].Matched
|
||||
quarter.Late += months[i].Late
|
||||
quarter.Early += months[i].Early
|
||||
quarter.NoSchedule += months[i].NoSchedule
|
||||
}
|
||||
oaAttendFillCard(&quarter)
|
||||
quarters = append(quarters, quarter)
|
||||
|
||||
yearCard.Scheduled += quarter.Scheduled
|
||||
yearCard.Clocked += quarter.Clocked
|
||||
yearCard.Matched += quarter.Matched
|
||||
yearCard.Late += quarter.Late
|
||||
yearCard.Early += quarter.Early
|
||||
yearCard.NoSchedule += quarter.NoSchedule
|
||||
}
|
||||
oaAttendFillCard(&yearCard)
|
||||
return quarters, yearCard
|
||||
}
|
||||
|
||||
// oaAttendDayStat 单日全员考勤数据,前端用日历格子展示。
|
||||
type oaAttendDayStat struct {
|
||||
Date string `json:"date"`
|
||||
Weekday int `json:"weekday"`
|
||||
Scheduled int64 `json:"scheduled"`
|
||||
Clocked int64 `json:"clocked"`
|
||||
Matched int64 `json:"matched"`
|
||||
Late int64 `json:"late"`
|
||||
Early int64 `json:"early"`
|
||||
Missing int64 `json:"missing"`
|
||||
NoSchedule int64 `json:"noschedule"`
|
||||
AttendanceRate int `json:"attendance_rate"`
|
||||
Future bool `json:"future"`
|
||||
}
|
||||
|
||||
// oaAttendDayCount 按日排班条数。
|
||||
type oaAttendDayCount struct {
|
||||
Day string `orm:"column(d)"`
|
||||
Cnt int64 `orm:"column(cnt)"`
|
||||
}
|
||||
|
||||
// oaAttendDayRecord 按日打卡统计。
|
||||
type oaAttendDayRecord struct {
|
||||
Day string `orm:"column(d)"`
|
||||
Clocked int64 `orm:"column(clocked)"`
|
||||
Matched int64 `orm:"column(matched)"`
|
||||
Late int64 `orm:"column(late)"`
|
||||
Early int64 `orm:"column(early)"`
|
||||
NoShift int64 `orm:"column(noschedule)"`
|
||||
}
|
||||
|
||||
// oaAttendDailyStats 区间内逐日的全员考勤数据(月/季/年日历共用)。
|
||||
func oaAttendDailyStats(tid uint64, startDate, endDate string, orgIDs []uint64) []oaAttendDayStat {
|
||||
result := make([]oaAttendDayStat, 0, 32)
|
||||
|
||||
entryFilter, entryArgs := oaAttendOrgSQL(orgIDs)
|
||||
dayCount := map[string]int64{}
|
||||
var entryRows []oaAttendDayCount
|
||||
entrySQL := "SELECT DATE_FORMAT(schedule_date, '%Y-%m-%d') AS d, COUNT(*) AS cnt" +
|
||||
" FROM yz_backend_oa_schedule_entry" +
|
||||
" WHERE tid = ? AND is_deleted = 0 AND schedule_date >= ? AND schedule_date <= ?" + entryFilter +
|
||||
" GROUP BY schedule_date"
|
||||
entryQueryArgs := append([]interface{}{tid, startDate, endDate}, entryArgs...)
|
||||
if _, err := models.Orm.Raw(entrySQL, entryQueryArgs...).QueryRows(&entryRows); err == nil {
|
||||
for _, row := range entryRows {
|
||||
dayCount[row.Day] = row.Cnt
|
||||
}
|
||||
}
|
||||
|
||||
recordFilter, recordArgs := oaAttendOrgSQL(orgIDs)
|
||||
recordAgg := map[string]oaAttendDayRecord{}
|
||||
var recordRows []oaAttendDayRecord
|
||||
recordSQL := "SELECT DATE_FORMAT(attend_date, '%Y-%m-%d') AS d," +
|
||||
" CAST(SUM(clock_in_time IS NOT NULL) AS SIGNED) AS clocked," +
|
||||
" CAST(SUM(clock_in_time IS NOT NULL AND match_status = 1) AS SIGNED) AS matched," +
|
||||
" CAST(SUM(clock_in_status = 2) AS SIGNED) AS late," +
|
||||
" CAST(SUM(clock_out_status = 2) AS SIGNED) AS early," +
|
||||
" CAST(SUM(match_status = 2) AS SIGNED) AS noschedule" +
|
||||
" FROM yz_backend_oa_attendance" +
|
||||
" WHERE tid = ? AND attend_date >= ? AND attend_date <= ?" + recordFilter +
|
||||
" GROUP BY attend_date"
|
||||
recordQueryArgs := append([]interface{}{tid, startDate, endDate}, recordArgs...)
|
||||
if _, err := models.Orm.Raw(recordSQL, recordQueryArgs...).QueryRows(&recordRows); err == nil {
|
||||
for _, row := range recordRows {
|
||||
recordAgg[row.Day] = row
|
||||
}
|
||||
}
|
||||
|
||||
today := time.Now().Format("2006-01-02")
|
||||
for date := startDate; date != "" && date <= endDate; date = nextOaDate(date, 1) {
|
||||
record := recordAgg[date]
|
||||
item := oaAttendDayStat{
|
||||
Date: date,
|
||||
Weekday: oaAttendWeekday(date),
|
||||
Scheduled: dayCount[date],
|
||||
Clocked: record.Clocked,
|
||||
Matched: record.Matched,
|
||||
Late: record.Late,
|
||||
Early: record.Early,
|
||||
NoSchedule: record.NoShift,
|
||||
Future: date > today,
|
||||
}
|
||||
item.Missing = item.Scheduled - item.Matched
|
||||
if item.Missing < 0 {
|
||||
item.Missing = 0
|
||||
}
|
||||
if item.Scheduled > 0 {
|
||||
item.AttendanceRate = int(item.Matched * 100 / item.Scheduled)
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// oaAttendOrgNames 租户组织 ID → 名称。
|
||||
func oaAttendOrgNames(tid uint64) map[uint64]string {
|
||||
var rows []models.BackendOrganization
|
||||
_, err := models.Orm.QueryTable(new(models.BackendOrganization)).
|
||||
Filter("tid", tid).
|
||||
Filter("delete_time__isnull", true).
|
||||
All(&rows)
|
||||
names := map[uint64]string{}
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
return names
|
||||
}
|
||||
for i := range rows {
|
||||
names[rows[i].ID] = rows[i].OrgName
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// oaAttendRoster 统计范围内的在职员工(按组织过滤,关键字在内存里过滤)。
|
||||
func oaAttendRoster(tid uint64, orgIDs []uint64) ([]models.BackendEmployee, bool, error) {
|
||||
qs := employeeBaseQuery(tid)
|
||||
if len(orgIDs) > 0 {
|
||||
values := make([]string, 0, len(orgIDs))
|
||||
for _, id := range orgIDs {
|
||||
values = append(values, strconv.FormatUint(id, 10))
|
||||
}
|
||||
qs = qs.Filter("department__in", values)
|
||||
}
|
||||
var rows []models.BackendEmployee
|
||||
_, err := qs.OrderBy("department", "id").Limit(2000).All(&rows)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
return nil, false, err
|
||||
}
|
||||
return rows, len(rows) >= 2000, nil
|
||||
}
|
||||
|
||||
// oaAttendEmpAggs 区间内的员工聚合:scheduled 来自排班表,其余来自考勤表。
|
||||
func oaAttendEmpAggs(tid uint64, startDate, endDate string, orgIDs []uint64) (map[uint]*oaAttendAgg, map[uint64]*oaAttendAgg, []oaAttendRowAgg) {
|
||||
aggByEmp := map[uint]*oaAttendAgg{}
|
||||
aggByUid := map[uint64]*oaAttendAgg{}
|
||||
|
||||
entryFilter, entryArgs := oaAttendOrgSQL(orgIDs)
|
||||
var entryRows []oaAttendEmpCount
|
||||
entrySQL := "SELECT employee_id, COUNT(*) AS cnt FROM yz_backend_oa_schedule_entry" +
|
||||
" WHERE tid = ? AND is_deleted = 0 AND schedule_date >= ? AND schedule_date <= ?" + entryFilter +
|
||||
" GROUP BY employee_id"
|
||||
entryQueryArgs := append([]interface{}{tid, startDate, endDate}, entryArgs...)
|
||||
if _, err := models.Orm.Raw(entrySQL, entryQueryArgs...).QueryRows(&entryRows); err == nil {
|
||||
for _, row := range entryRows {
|
||||
if row.EmployeeID == 0 {
|
||||
continue
|
||||
}
|
||||
item, ok := aggByEmp[row.EmployeeID]
|
||||
if !ok {
|
||||
item = &oaAttendAgg{}
|
||||
aggByEmp[row.EmployeeID] = item
|
||||
}
|
||||
item.scheduled += row.Cnt
|
||||
}
|
||||
}
|
||||
|
||||
recordFilter, recordArgs := oaAttendOrgSQL(orgIDs)
|
||||
var recordRows []oaAttendRowAgg
|
||||
recordSQL := "SELECT employee_id, uid," +
|
||||
" MAX(employee_name) AS employee_name, MAX(employee_account) AS employee_account," +
|
||||
" MAX(org_id) AS org_id, MAX(org_name) AS org_name," +
|
||||
" CAST(SUM(clock_in_time IS NOT NULL) AS SIGNED) AS clocked," +
|
||||
" CAST(SUM(clock_in_time IS NOT NULL AND match_status = 1) AS SIGNED) AS matched," +
|
||||
" CAST(SUM(clock_in_status = 2) AS SIGNED) AS late," +
|
||||
" CAST(SUM(clock_out_status = 2) AS SIGNED) AS early," +
|
||||
" CAST(SUM(match_status = 2) AS SIGNED) AS noschedule," +
|
||||
" CAST(SUM(clock_in_status = 4 OR clock_out_status = 4) AS SIGNED) AS offtime" +
|
||||
" FROM yz_backend_oa_attendance" +
|
||||
" WHERE tid = ? AND attend_date >= ? AND attend_date <= ?" + recordFilter +
|
||||
" GROUP BY employee_id, uid"
|
||||
recordQueryArgs := append([]interface{}{tid, startDate, endDate}, recordArgs...)
|
||||
if _, err := models.Orm.Raw(recordSQL, recordQueryArgs...).QueryRows(&recordRows); err == nil {
|
||||
for _, row := range recordRows {
|
||||
if row.EmployeeID == 0 {
|
||||
item, ok := aggByUid[row.Uid]
|
||||
if !ok {
|
||||
item = &oaAttendAgg{}
|
||||
aggByUid[row.Uid] = item
|
||||
}
|
||||
item.clocked += row.Clocked
|
||||
item.matched += row.Matched
|
||||
item.late += row.Late
|
||||
item.early += row.Early
|
||||
item.noShift += row.NoShift
|
||||
item.offTime += row.OffTime
|
||||
continue
|
||||
}
|
||||
item, ok := aggByEmp[row.EmployeeID]
|
||||
if !ok {
|
||||
item = &oaAttendAgg{}
|
||||
aggByEmp[row.EmployeeID] = item
|
||||
}
|
||||
item.clocked += row.Clocked
|
||||
item.matched += row.Matched
|
||||
item.late += row.Late
|
||||
item.early += row.Early
|
||||
item.noShift += row.NoShift
|
||||
item.offTime += row.OffTime
|
||||
}
|
||||
}
|
||||
return aggByEmp, aggByUid, recordRows
|
||||
}
|
||||
|
||||
// Summary 全员考勤汇总(月 / 季 / 年)。
|
||||
// GET /backend/oa/attendance/summary?period=month&date=2026-11-14&org_id=&include_child=1&keyword=&page=&pageSize=
|
||||
func (c *BackendOaAttendanceController) Summary() {
|
||||
tid, _, ok := c.atOperator()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
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.atFail(400, 400, "日期参数无效")
|
||||
return
|
||||
}
|
||||
endDate = oaAttendClampRange(startDate, endDate)
|
||||
|
||||
page, _ := c.GetInt("page", 1)
|
||||
pageSize, _ := c.GetInt("pageSize", 20)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > oaAttendMaxPageSize {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
orgID := uint64(0)
|
||||
if raw := strings.TrimSpace(c.GetString("org_id")); raw != "" {
|
||||
if parsed, err := strconv.ParseUint(raw, 10, 64); err == nil {
|
||||
orgID = parsed
|
||||
}
|
||||
}
|
||||
orgIDs := oaAttendOrgIDs(tid, orgID, c.GetString("include_child") != "0")
|
||||
keyword := strings.ToLower(strings.TrimSpace(c.GetString("keyword")))
|
||||
|
||||
year := 0
|
||||
if parsed, parseErr := time.ParseInLocation("2006-01-02", anchor, time.Local); parseErr == nil {
|
||||
year = parsed.Year()
|
||||
} else {
|
||||
year = time.Now().Year()
|
||||
}
|
||||
|
||||
monthCards := oaAttendMonthCards(tid, year, orgIDs)
|
||||
quarterCards, yearCard := oaAttendAggregateCards(monthCards, year)
|
||||
|
||||
// 当前选中周期对应的考勤卡
|
||||
activeKey := fmt.Sprintf("%d-%02d", year, int(mustParseMonth(anchor)))
|
||||
activeCard := yearCard
|
||||
switch period {
|
||||
case "year":
|
||||
activeKey = fmt.Sprintf("%d", year)
|
||||
activeCard = yearCard
|
||||
case "quarter":
|
||||
quarter := (int(mustParseMonth(anchor)) - 1) / 3
|
||||
activeKey = fmt.Sprintf("%d-Q%d", year, quarter+1)
|
||||
if quarter < len(quarterCards) {
|
||||
activeCard = quarterCards[quarter]
|
||||
}
|
||||
default:
|
||||
if month := int(mustParseMonth(anchor)); month >= 1 && month <= len(monthCards) {
|
||||
activeCard = monthCards[month-1]
|
||||
}
|
||||
}
|
||||
|
||||
aggByEmp, aggByUid, recordRows := oaAttendEmpAggs(tid, startDate, endDate, orgIDs)
|
||||
orgNames := oaAttendOrgNames(tid)
|
||||
employees, truncated, err := oaAttendRoster(tid, orgIDs)
|
||||
if err != nil {
|
||||
c.atFail(500, 500, "查询员工列表失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
rows := make([]oaAttendSummaryRow, 0, len(employees)+len(aggByUid))
|
||||
for i := range employees {
|
||||
employee := employees[i]
|
||||
position := ""
|
||||
if employee.Position != nil {
|
||||
position = strings.TrimSpace(*employee.Position)
|
||||
}
|
||||
if keyword != "" &&
|
||||
!strings.Contains(strings.ToLower(employee.Name), keyword) &&
|
||||
!strings.Contains(strings.ToLower(employee.Account), keyword) {
|
||||
continue
|
||||
}
|
||||
empOrgID := scEmployeeDepartmentID(employee)
|
||||
row := oaAttendSummaryRow{
|
||||
Key: fmt.Sprintf("e%d", employee.ID),
|
||||
EmployeeID: employee.ID,
|
||||
EmployeeName: employee.Name,
|
||||
EmployeeAccount: employee.Account,
|
||||
OrgID: empOrgID,
|
||||
OrgName: orgNames[empOrgID],
|
||||
Position: position,
|
||||
Bound: true,
|
||||
}
|
||||
if agg, exists := aggByEmp[employee.ID]; exists {
|
||||
row.ScheduledDays = agg.scheduled
|
||||
row.ClockedDays = agg.clocked
|
||||
row.LateCount = agg.late
|
||||
row.EarlyCount = agg.early
|
||||
row.NoScheduleCount = agg.noShift
|
||||
row.OffTimeCount = agg.offTime
|
||||
row.MissingDays = agg.scheduled - agg.matched
|
||||
if row.MissingDays < 0 {
|
||||
row.MissingDays = 0
|
||||
}
|
||||
if agg.scheduled > 0 {
|
||||
row.AttendanceRate = int(agg.matched * 100 / agg.scheduled)
|
||||
}
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
// 未绑定员工档案但打过卡的人:按登录身份 uid 单列,保证数据不丢
|
||||
for _, record := range recordRows {
|
||||
if record.EmployeeID != 0 {
|
||||
continue
|
||||
}
|
||||
agg := aggByUid[record.Uid]
|
||||
if agg == nil {
|
||||
continue
|
||||
}
|
||||
if keyword != "" &&
|
||||
!strings.Contains(strings.ToLower(record.EmployeeName), keyword) &&
|
||||
!strings.Contains(strings.ToLower(record.EmployeeAccount), keyword) {
|
||||
continue
|
||||
}
|
||||
row := oaAttendSummaryRow{
|
||||
Key: fmt.Sprintf("u%d", record.Uid),
|
||||
Uid: record.Uid,
|
||||
EmployeeID: 0,
|
||||
EmployeeName: record.EmployeeName,
|
||||
EmployeeAccount: record.EmployeeAccount,
|
||||
OrgID: record.OrgID,
|
||||
OrgName: record.OrgName,
|
||||
Bound: false,
|
||||
ClockedDays: agg.clocked,
|
||||
LateCount: agg.late,
|
||||
EarlyCount: agg.early,
|
||||
NoScheduleCount: agg.noShift,
|
||||
OffTimeCount: agg.offTime,
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
if rows[i].Bound != rows[j].Bound {
|
||||
return rows[i].Bound
|
||||
}
|
||||
if rows[i].OrgID != rows[j].OrgID {
|
||||
return rows[i].OrgID < rows[j].OrgID
|
||||
}
|
||||
return rows[i].EmployeeName < rows[j].EmployeeName
|
||||
})
|
||||
|
||||
total := len(rows)
|
||||
start := (page - 1) * pageSize
|
||||
if start > total {
|
||||
start = total
|
||||
}
|
||||
end := start + pageSize
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
|
||||
c.atOK(map[string]interface{}{
|
||||
"period": period,
|
||||
"label": label,
|
||||
"anchor": anchor,
|
||||
"year": year,
|
||||
"start_date": startDate,
|
||||
"end_date": endDate,
|
||||
"active_key": activeKey,
|
||||
"stats": activeCard,
|
||||
"cards": map[string]interface{}{
|
||||
"months": monthCards,
|
||||
"quarters": quarterCards,
|
||||
"year": yearCard,
|
||||
},
|
||||
"calendar": oaAttendDailyStats(tid, startDate, endDate, orgIDs),
|
||||
"list": rows[start:end],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": pageSize,
|
||||
"truncated": truncated,
|
||||
})
|
||||
}
|
||||
|
||||
// mustParseMonth 取日期字符串的月份,解析失败返回当前月。
|
||||
func mustParseMonth(date string) time.Month {
|
||||
if parsed, ok := scParseDate(date); ok {
|
||||
return parsed.Month()
|
||||
}
|
||||
return time.Now().Month()
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
)
|
||||
|
||||
// TestOaAttendPeriodRange 月 / 季 / 年区间换算(含闰年 2 月)。
|
||||
func TestOaAttendPeriodRange(t *testing.T) {
|
||||
cases := []struct {
|
||||
period, anchor, start, end string
|
||||
}{
|
||||
{"month", "2026-11-14", "2026-11-01", "2026-11-30"},
|
||||
{"month", "2024-02-10", "2024-02-01", "2024-02-29"},
|
||||
{"quarter", "2026-11-14", "2026-10-01", "2026-12-31"},
|
||||
{"quarter", "2026-01-05", "2026-01-01", "2026-03-31"},
|
||||
{"year", "2026-07-01", "2026-01-01", "2026-12-31"},
|
||||
}
|
||||
for _, item := range cases {
|
||||
start, end, label, ok := oaAttendPeriodRange(item.period, item.anchor)
|
||||
if !ok {
|
||||
t.Fatalf("%s %s 解析失败", item.period, item.anchor)
|
||||
}
|
||||
if start != item.start || end != item.end {
|
||||
t.Errorf("%s %s 期望 %s~%s,得到 %s~%s", item.period, item.anchor,
|
||||
item.start, item.end, start, end)
|
||||
}
|
||||
if label == "" {
|
||||
t.Errorf("%s %s 缺少周期标题", item.period, item.anchor)
|
||||
}
|
||||
}
|
||||
|
||||
if _, _, _, ok := oaAttendPeriodRange("month", "2026-13-01"); ok {
|
||||
t.Error("非法日期应返回 ok=false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOaAttendAggregateCards 季度卡与年度卡由月度卡累加得出。
|
||||
func TestOaAttendAggregateCards(t *testing.T) {
|
||||
months := make([]oaAttendCard, 0, 12)
|
||||
for month := 1; month <= 12; month++ {
|
||||
card := oaAttendCard{Scheduled: 10, Matched: 8, Clocked: 9}
|
||||
oaAttendFillCard(&card)
|
||||
months = append(months, card)
|
||||
}
|
||||
|
||||
quarters, yearCard := oaAttendAggregateCards(months, 2026)
|
||||
if len(quarters) != 4 {
|
||||
t.Fatalf("期望 4 个季度卡,得到 %d", len(quarters))
|
||||
}
|
||||
if quarters[0].Scheduled != 30 || quarters[0].Matched != 24 {
|
||||
t.Errorf("Q1 期望 应出勤 30 / 匹配 24,得到 %d / %d", quarters[0].Scheduled, quarters[0].Matched)
|
||||
}
|
||||
if quarters[0].Missing != 6 || quarters[0].AttendanceRate != 80 {
|
||||
t.Errorf("Q1 期望 缺卡 6 / 出勤率 80,得到 %d / %d", quarters[0].Missing, quarters[0].AttendanceRate)
|
||||
}
|
||||
if yearCard.Scheduled != 120 || yearCard.Matched != 96 || yearCard.AttendanceRate != 80 {
|
||||
t.Errorf("年度卡累计异常:%+v", yearCard)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOaShiftWindow 班次时间窗:普通班次与跨天班次都应换算成分钟区间。
|
||||
func TestOaShiftWindow(t *testing.T) {
|
||||
start, end, ok := oaShiftWindow("09:00", "18:00")
|
||||
if !ok || start != 540 || end != 1080 {
|
||||
t.Fatalf("09:00-18:00 期望 540-1080,得到 %d-%d ok=%v", start, end, ok)
|
||||
}
|
||||
|
||||
start, end, ok = oaShiftWindow("22:00", "06:00")
|
||||
if !ok || start != 1320 || end != 1800 {
|
||||
t.Fatalf("22:00-06:00 期望 1320-1800,得到 %d-%d ok=%v", start, end, ok)
|
||||
}
|
||||
|
||||
if _, _, ok := oaShiftWindow("", "18:00"); ok {
|
||||
t.Error("缺少上班时间时应返回 ok=false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOaAttendClockInStatus 签到判定:正常 / 迟到 / 过早(不在时段)/ 无排班。
|
||||
func TestOaAttendClockInStatus(t *testing.T) {
|
||||
matched := &models.OaAttendance{
|
||||
WorkStart: "09:00",
|
||||
WorkEnd: "18:00",
|
||||
MatchStatus: models.OaAttendMatchOK,
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
now string
|
||||
status int8
|
||||
}{
|
||||
{"08:55", models.OaAttendClockNormal}, // 容差内早到
|
||||
{"09:05", models.OaAttendClockNormal}, // 容差内晚到
|
||||
{"09:30", models.OaAttendClockLate}, // 迟到
|
||||
{"07:30", models.OaAttendClockOffTime}, // 提前超过 60 分钟
|
||||
}
|
||||
for _, item := range cases {
|
||||
now, err := time.ParseInLocation("2006-01-02 15:04", "2026-11-14 "+item.now, time.Local)
|
||||
if err != nil {
|
||||
t.Fatalf("解析时间失败: %v", err)
|
||||
}
|
||||
status, _ := oaAttendClockInStatus(matched, now)
|
||||
if status != item.status {
|
||||
t.Errorf("%s 期望状态 %d,得到 %d", item.now, item.status, status)
|
||||
}
|
||||
}
|
||||
|
||||
// 未匹配排班:不阻断打卡,状态记为「无排班」
|
||||
missing := &models.OaAttendance{MatchStatus: models.OaAttendMatchMissing}
|
||||
now := time.Date(2026, 11, 14, 9, 0, 0, 0, time.Local)
|
||||
if status, msg := oaAttendClockInStatus(missing, now); status != models.OaAttendClockNoShift || msg == "" {
|
||||
t.Errorf("未匹配排班期望状态 %d 且有提醒文案,得到 %d %q", models.OaAttendClockNoShift, status, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOaAttendClockOutStatus 签退判定:早退与跨天班次凌晨签退。
|
||||
func TestOaAttendClockOutStatus(t *testing.T) {
|
||||
row := &models.OaAttendance{
|
||||
WorkStart: "09:00",
|
||||
WorkEnd: "18:00",
|
||||
MatchStatus: models.OaAttendMatchOK,
|
||||
}
|
||||
early := time.Date(2026, 11, 14, 17, 0, 0, 0, time.Local)
|
||||
if status, msg := oaAttendClockOutStatus(row, early); status != models.OaAttendClockLate || msg == "" {
|
||||
t.Errorf("17:00 签退期望早退,得到 %d %q", status, msg)
|
||||
}
|
||||
|
||||
night := &models.OaAttendance{
|
||||
WorkStart: "22:00",
|
||||
WorkEnd: "06:00",
|
||||
MatchStatus: models.OaAttendMatchOK,
|
||||
}
|
||||
// 跨天班次:次日 06:10 签退应视为正常
|
||||
nextMorning := time.Date(2026, 11, 15, 6, 10, 0, 0, time.Local)
|
||||
if status, msg := oaAttendClockOutStatus(night, nextMorning); status != models.OaAttendClockNormal || msg != "" {
|
||||
t.Errorf("06:10 跨天签退期望正常,得到 %d %q", status, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOaAttendDayResult 当日考勤结果口径:缺卡 > 无排班 > 迟到 > 早退 > 时段异常 > 未签退 > 正常。
|
||||
func TestOaAttendDayResult(t *testing.T) {
|
||||
if _, text := oaAttendDayResult("", 0, "", 0, models.OaAttendMatchMissing); text != "缺卡" {
|
||||
t.Errorf("未签到应为缺卡,得到 %q", text)
|
||||
}
|
||||
if _, text := oaAttendDayResult("09:00", models.OaAttendClockOffTime, "18:00", models.OaAttendClockNormal, models.OaAttendMatchOK); text != "时段异常" {
|
||||
t.Errorf("时段异常期望「时段异常」,得到 %q", text)
|
||||
}
|
||||
|
||||
if _, text := oaAttendDayResult("09:00", models.OaAttendClockNormal, "18:00", models.OaAttendClockNormal, models.OaAttendMatchOK); text != "正常" {
|
||||
t.Errorf("正常打卡期望「正常」,得到 %q", text)
|
||||
}
|
||||
if _, text := oaAttendDayResult("09:30", models.OaAttendClockLate, "", 0, models.OaAttendMatchOK); text != "迟到" {
|
||||
t.Errorf("迟到期望「迟到」,得到 %q", text)
|
||||
}
|
||||
if _, text := oaAttendDayResult("09:00", models.OaAttendClockNormal, "17:00", models.OaAttendClockLate, models.OaAttendMatchOK); text != "早退" {
|
||||
t.Errorf("早退期望「早退」,得到 %q", text)
|
||||
}
|
||||
if _, text := oaAttendDayResult("09:00", models.OaAttendClockNoShift, "18:00", models.OaAttendClockNoShift, models.OaAttendMatchMissing); text != "无排班" {
|
||||
t.Errorf("无排班打卡期望「无排班」,得到 %q", text)
|
||||
}
|
||||
if _, text := oaAttendDayResult("09:00", models.OaAttendClockNormal, "", 0, models.OaAttendMatchOK); text != "未签退" {
|
||||
t.Errorf("缺签退期望「未签退」,得到 %q", text)
|
||||
}
|
||||
}
|
||||
@@ -843,20 +843,25 @@ func (c *BackendOaSchedulingController) MyBoard() {
|
||||
}
|
||||
|
||||
// scFindSelfEmployee 把登录身份映射到本租户的员工档案。
|
||||
func (c *BackendOaSchedulingController) scFindSelfEmployee(tid, identityID uint64, username string) (models.BackendEmployee, bool) {
|
||||
return findSelfEmployee(tid, identityID, username)
|
||||
}
|
||||
|
||||
// findSelfEmployee 把登录身份映射到本租户的员工档案(包级函数,排班与考勤共用)。
|
||||
// 优先取认证中心「身份+租户」绑定记录里的账号(与 /auth/userinfo 同源,最可靠),
|
||||
// 其次用令牌 username 按「账号 → 姓名」匹配。
|
||||
func (c *BackendOaSchedulingController) scFindSelfEmployee(tid, identityID uint64, username string) (models.BackendEmployee, bool) {
|
||||
func findSelfEmployee(tid, identityID uint64, username string) (models.BackendEmployee, bool) {
|
||||
var employee models.BackendEmployee
|
||||
|
||||
if identityID > 0 {
|
||||
if bind, err := authSvc.GetTenantUser(identityID, tid); err == nil && bind != nil {
|
||||
if v := authBindValue(bind.Account); v != "" {
|
||||
if emp, found := c.scEmployeeByField(tid, "account", v); found {
|
||||
if emp, found := findEmployeeByField(tid, "account", v); found {
|
||||
return emp, true
|
||||
}
|
||||
}
|
||||
if v := authBindValue(bind.Name); v != "" {
|
||||
if emp, found := c.scEmployeeByField(tid, "name", v); found {
|
||||
if emp, found := findEmployeeByField(tid, "name", v); found {
|
||||
return emp, true
|
||||
}
|
||||
}
|
||||
@@ -866,7 +871,7 @@ func (c *BackendOaSchedulingController) scFindSelfEmployee(tid, identityID uint6
|
||||
username = strings.TrimSpace(username)
|
||||
if username != "" {
|
||||
for _, field := range []string{"account", "name"} {
|
||||
if emp, found := c.scEmployeeByField(tid, field, username); found {
|
||||
if emp, found := findEmployeeByField(tid, field, username); found {
|
||||
return emp, true
|
||||
}
|
||||
}
|
||||
@@ -876,8 +881,13 @@ func (c *BackendOaSchedulingController) scFindSelfEmployee(tid, identityID uint6
|
||||
|
||||
// scEmployeeByField 在本租户员工档案中按指定字段匹配,取第一条有效记录
|
||||
func (c *BackendOaSchedulingController) scEmployeeByField(tid uint64, field, value string) (models.BackendEmployee, bool) {
|
||||
return findEmployeeByField(tid, field, value)
|
||||
}
|
||||
|
||||
// findEmployeeByField 包级实现:本租户员工档案按指定字段匹配,取第一条有效记录。
|
||||
func findEmployeeByField(tid uint64, field, value string) (models.BackendEmployee, bool) {
|
||||
var rows []models.BackendEmployee
|
||||
if _, err := c.scBaseEmployeeQuery(tid).
|
||||
if _, err := employeeBaseQuery(tid).
|
||||
Filter(field, value).
|
||||
OrderBy("id").
|
||||
Limit(1).
|
||||
|
||||
@@ -80,6 +80,11 @@ func (index *scOrgIndex) name(id uint64) string {
|
||||
|
||||
// scBaseEmployeeQuery 排班可选员工:未删除、非离职。
|
||||
func (c *BackendOaSchedulingController) scBaseEmployeeQuery(tid uint64) orm.QuerySeter {
|
||||
return employeeBaseQuery(tid)
|
||||
}
|
||||
|
||||
// employeeBaseQuery 本租户在职员工查询(包级函数,排班与考勤共用)。
|
||||
func employeeBaseQuery(tid uint64) orm.QuerySeter {
|
||||
return models.Orm.QueryTable(new(models.BackendEmployee)).
|
||||
Filter("tid", tid).
|
||||
Filter("delete_time__isnull", true).
|
||||
|
||||
@@ -141,6 +141,85 @@ func (c *BackendSiteSettingsController) GetNormalInfos() {
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// backendBrandOutput 后台顶栏品牌(Logo 优先用平台配置)
|
||||
type backendBrandOutput struct {
|
||||
Sitename string `json:"sitename"` // 主标题
|
||||
Subtitle string `json:"subtitle"` // 副标题
|
||||
Intro string `json:"intro"`
|
||||
Ico string `json:"ico"` // 浏览器标签页图标(favicon)
|
||||
LogoSquare string `json:"logoSquare"` // 正方形 Logo(浅色主题 / 顶栏小图标)
|
||||
LogoRect string `json:"logoRect"` // 长方形 Logo(浅色主题 / 横向品牌图)
|
||||
Logo string `json:"logo"` // 官网深色 Logo(浅色背景)
|
||||
Logow string `json:"logow"` // 官网浅色 Logo(深色背景)
|
||||
|
||||
// 深色主题专用 Logo:留空时前端回退到上面的浅色主题版本
|
||||
LogoSquareDark string `json:"logoSquareDark"`
|
||||
LogoRectDark string `json:"logoRectDark"`
|
||||
}
|
||||
|
||||
// GetBrand GET /backend/brand 后台顶栏品牌信息(顶栏左上角 Logo / 站点名 / 简介)
|
||||
// 取值优先级:
|
||||
// 1. 平台「系统设置 - 平台设置 - 平台信息」(logo_square / logo_rect / sitename / platform_intro)
|
||||
// 2. 平台「官网管理 - 站点设置」(yz_platform_site_setting:logo 深色 / logow 浅色)
|
||||
// 3. 平台「平台设置 - 登录页设置」的 login_logo(仅在以上都没配 logo 时兜底)
|
||||
// 4. 当前租户自己的站点设置(yz_tenant_site_setting,租户在 backend「站点设置」上传的图片)
|
||||
//
|
||||
// 都没有配置时返回空串,前端保留内置的品牌占位标记。
|
||||
func (c *BackendSiteSettingsController) GetBrand() {
|
||||
claims, err := c.claimsByPath()
|
||||
if err != nil {
|
||||
code := identity.Status(err)
|
||||
c.jsonErr(code, code, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
tags := models.GetPlatformSiteTagMap()
|
||||
out := backendBrandOutput{
|
||||
Sitename: strings.TrimSpace(tags["sitename"]),
|
||||
Subtitle: strings.TrimSpace(tags["subtitle"]),
|
||||
Intro: strings.TrimSpace(tags["platform_intro"]),
|
||||
Ico: strings.TrimSpace(tags["ico"]),
|
||||
LogoSquare: strings.TrimSpace(tags["logo_square"]),
|
||||
LogoRect: strings.TrimSpace(tags["logo_rect"]),
|
||||
LogoSquareDark: strings.TrimSpace(tags["logo_square_dark"]),
|
||||
LogoRectDark: strings.TrimSpace(tags["logo_rect_dark"]),
|
||||
Logo: strings.TrimSpace(tags["logo"]),
|
||||
Logow: strings.TrimSpace(tags["logow"]),
|
||||
}
|
||||
if out.Logo == "" {
|
||||
out.Logo = strings.TrimSpace(tags["login_logo"])
|
||||
}
|
||||
if out.Logo == "" {
|
||||
out.Logo = out.LogoRect
|
||||
}
|
||||
|
||||
// 平台未配置的项回落到租户站点设置,避免顶栏退化成占位标记
|
||||
if out.Logo == "" || out.Logow == "" || out.Sitename == "" {
|
||||
if tid := c.resolveBackendTenantID(claims, nil); tid > 0 {
|
||||
var rows []models.TenantSiteSetting
|
||||
if _, qerr := models.Orm.QueryTable(new(models.TenantSiteSetting)).
|
||||
Filter("tid", tid).
|
||||
Filter("delete_time__isnull", true).
|
||||
Limit(1).
|
||||
All(&rows); qerr == nil && len(rows) > 0 {
|
||||
r := rows[0]
|
||||
if out.Logo == "" {
|
||||
out.Logo = strings.TrimSpace(r.Logo)
|
||||
}
|
||||
if out.Logow == "" {
|
||||
out.Logow = strings.TrimSpace(r.Logow)
|
||||
}
|
||||
if out.Sitename == "" {
|
||||
out.Sitename = strings.TrimSpace(r.Sitename)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": out}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
type backendNormalInfosPayload struct {
|
||||
// 前端会传 tid(但我们仍优先使用 token 的 tenant_id)
|
||||
Tid interface{} `json:"tid"`
|
||||
|
||||
Reference in new issue
Block a user