增加ai办公,考勤管理

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

No files matched your search

+472
View File
@@ -0,0 +1,472 @@
package controllers
import (
"encoding/json"
"io"
"strings"
"time"
"server/models"
"server/pkg/identity"
"server/pkg/jwtutil"
authSvc "server/services/auth"
"github.com/beego/beego/v2/client/orm"
)
// 移动端 OA 考勤打卡接口(挂在 AppOaController 上)。
//
// 与 Web 后台 /backend/oa/attendance/* 共用同一套数据与判定逻辑:
// - 打卡:oaAttendPunch(联动排班明细,匹配不上只提醒、仍可签到);
// - 记录:本人维度,只能看到自己的考勤。
//
// 员工档案缺失时仍允许打卡(employee_id=0,按登录身份 uid 归档),
// 保证「排班 / 人事档案没配好也能先签到」,由管理员后续核对。
// oaAttendAppClaims 取 app 令牌并确认已归属租户;失败时返回可直接输出的状态码与文案。
func (c *AppOaController) oaAttendAppClaims() (*jwtutil.Claims, int, string) {
claims, err := c.oaClaims()
if err != nil {
code := identity.Status(err)
return nil, code, err.Error()
}
if claims.TenantId <= 0 {
return nil, 403, "当前账号未绑定企业,无法打卡"
}
return claims, 200, ""
}
// oaAttendSelf 解析当前登录者对应的员工档案;未绑定时返回 nil 与兜底姓名。
func (c *AppOaController) oaAttendSelf(claims *jwtutil.Claims) (*models.BackendEmployee, string) {
tid := uint64(claims.TenantId)
uid := uint64(claims.UserID)
if employee, ok := findSelfEmployee(tid, uid, claims.Username); ok {
return &employee, employee.Name
}
name := strings.TrimSpace(claims.Username)
if bind, err := authSvc.GetTenantUser(uid, tid); err == nil && bind != nil {
if v := authBindValue(bind.Name); v != "" {
name = v
} else if v := authBindValue(bind.Account); v != "" {
name = v
}
}
return nil, name
}
// oaAttendEmployeeMap 当前登录者的员工信息(未绑定时 bound=false)。
func oaAttendEmployeeMap(tid uint64, employee *models.BackendEmployee, fallbackName string) map[string]interface{} {
if employee == nil {
return map[string]interface{}{
"id": 0, "name": fallbackName, "account": "",
"org_id": 0, "org_name": "", "position": "", "bound": false,
}
}
orgID := scEmployeeDepartmentID(*employee)
position := ""
if employee.Position != nil {
position = strings.TrimSpace(*employee.Position)
}
return map[string]interface{}{
"id": employee.ID,
"name": employee.Name,
"account": employee.Account,
"org_id": orgID,
"org_name": oaAttendOrgName(tid, orgID),
"position": position,
"bound": true,
}
}
// oaAttendEntryMap 当日排班(未排班返回 nil)。
func oaAttendEntryMap(entry *models.OaScheduleEntry) map[string]interface{} {
if entry == nil {
return nil
}
return map[string]interface{}{
"id": entry.ID,
"shift_id": entry.ShiftID,
"shift_name": entry.ShiftName,
"short_name": entry.ShortName,
"color": entry.Color,
"work_start": entry.WorkStart,
"work_end": entry.WorkEnd,
"shift_text": oaAttendShiftText(entry.WorkStart, entry.WorkEnd),
"source": entry.Source,
"note": entry.Note,
}
}
// oaAttendTodayWarning 未匹配排班时的提醒文案(页面顶部横幅 + 打卡前二次确认共用)。
func oaAttendTodayWarning(entry *models.OaScheduleEntry, employee *models.BackendEmployee) string {
if entry != nil {
return ""
}
if employee == nil {
return "未匹配到你的员工档案与今日排班,仍可签到,签到后将标记为「无排班打卡」"
}
return "今日未匹配到排班,仍可签到,签到后将标记为「无排班打卡」,请联系管理员核对排班"
}
// oaAttendRecentDays 最近 N 天的排班 + 打卡合并视图(一次性批量查询,避免逐天查库)。
func oaAttendRecentDays(tid, uid uint64, employeeID uint, endDate string, days int) []map[string]interface{} {
if days < 1 || days > 31 {
days = 7
}
startDate := nextOaDate(endDate, -(days - 1))
if startDate == "" {
startDate = endDate
}
entries := map[string]*models.OaScheduleEntry{}
if employeeID > 0 {
var entryRows []models.OaScheduleEntry
_, _ = models.Orm.QueryTable(new(models.OaScheduleEntry)).
Filter("tid", tid).
Filter("employee_id", employeeID).
Filter("is_deleted", 0).
Filter("schedule_date__gte", startDate).
Filter("schedule_date__lte", endDate).
All(&entryRows)
for i := range entryRows {
entries[normalizeOaDate(entryRows[i].ScheduleDate)] = &entryRows[i]
}
}
records := map[string]*models.OaAttendance{}
qs := oaAttendQuery(tid).
Filter("attend_date__gte", startDate).
Filter("attend_date__lte", endDate)
if employeeID > 0 {
qs = qs.Filter("employee_id", employeeID)
} else if uid > 0 {
qs = qs.Filter("uid", uid)
}
var recordRows []models.OaAttendance
_, _ = qs.All(&recordRows)
for i := range recordRows {
records[normalizeOaDate(recordRows[i].AttendDate)] = &recordRows[i]
}
list := make([]map[string]interface{}, 0, days)
today := time.Now().Format("2006-01-02")
for date := startDate; date != "" && date <= endDate; date = nextOaDate(date, 1) {
entry := entries[date]
record := records[date]
item := map[string]interface{}{
"date": date,
"weekday": oaAttendWeekday(date),
"is_today": date == today,
"has_schedule": entry != nil,
"matched": entry != nil,
"shift_name": "",
"shift_text": "",
"color": "",
"clock_in_hm": "",
"clock_out_hm": "",
"day_status": oaDayStatusRest,
"day_status_text": "休息",
}
if entry != nil {
item["shift_name"] = entry.ShiftName
item["shift_text"] = oaAttendShiftText(entry.WorkStart, entry.WorkEnd)
item["color"] = entry.Color
}
if record != nil {
view := oaAttendView(record)
item["shift_name"] = view.ShiftName
item["shift_text"] = view.ShiftText
item["color"] = view.Color
item["clock_in_hm"] = view.ClockInHm
item["clock_out_hm"] = view.ClockOutHm
item["day_status"] = view.DayStatus
item["day_status_text"] = view.DayStatusText
item["has_schedule"] = entry != nil || view.MatchStatus == models.OaAttendMatchOK
} else if entry != nil {
item["day_status"] = oaDayStatusMissing
item["day_status_text"] = "未打卡"
}
list = append(list, item)
}
// 倒序(最近的日期在前)
for i, j := 0, len(list)-1; i < j; i, j = i+1, j-1 {
list[i], list[j] = list[j], list[i]
}
return list
}
// oaAttendPersonalStats 个人区间考勤统计(员工档案缺失时只统计打卡次数)。
func oaAttendPersonalStats(tid, uid uint64, employeeID uint, startDate, endDate string) map[string]interface{} {
baseCond := func() *orm.Condition {
cond := orm.NewCondition().
And("tid", tid).
And("attend_date__gte", startDate).
And("attend_date__lte", endDate)
if employeeID > 0 {
cond = cond.And("employee_id", employeeID)
} else {
cond = cond.And("uid", uid)
}
return cond
}
countWith := func(extra *orm.Condition) int64 {
cond := baseCond()
if extra != nil {
cond = cond.AndCond(extra)
}
n, _ := models.Orm.QueryTable(new(models.OaAttendance)).SetCond(cond).Count()
return n
}
scheduled := 0
if employeeID > 0 {
n, _ := models.Orm.QueryTable(new(models.OaScheduleEntry)).
Filter("tid", tid).
Filter("employee_id", employeeID).
Filter("is_deleted", 0).
Filter("schedule_date__gte", startDate).
Filter("schedule_date__lte", endDate).
Count()
scheduled = int(n)
}
clocked := countWith(orm.NewCondition().And("clock_in_time__isnull", false))
late := countWith(orm.NewCondition().And("clock_in_status", models.OaAttendClockLate))
early := countWith(orm.NewCondition().And("clock_out_status", models.OaAttendClockLate))
noSchedule := countWith(orm.NewCondition().And("match_status", models.OaAttendMatchMissing))
matchedClocked := countWith(orm.NewCondition().
And("clock_in_time__isnull", false).
And("match_status", models.OaAttendMatchOK))
missing := scheduled - int(matchedClocked)
if missing < 0 {
missing = 0
}
rate := 0
if scheduled > 0 {
rate = int(matchedClocked) * 100 / scheduled
}
return map[string]interface{}{
"scheduled": scheduled,
"clocked": clocked,
"late": late,
"early": early,
"noschedule": noSchedule,
"missing": missing,
"attendance_rate": rate,
}
}
// AttendanceToday GET /api/oa/attendance/today?days=7
// 今日打卡状态 + 当日排班 + 最近 N 天记录 + 本月统计。
// 未匹配排班时 warning 非空,前端据此提醒,但 can_clock_in/can_clock_out 仍为 true。
func (c *AppOaController) AttendanceToday() {
claims, code, msg := c.oaAttendAppClaims()
if msg != "" {
c.oaErr(code, code, msg)
return
}
tid := uint64(claims.TenantId)
uid := uint64(claims.UserID)
days, _ := c.GetInt("days", 7)
now := time.Now()
date := now.Format("2006-01-02")
employee, fallbackName := c.oaAttendSelf(claims)
employeeID := uint(0)
if employee != nil {
employeeID = employee.ID
}
entry := oaAttendEntryOf(tid, employeeID, date)
record := oaAttendFind(tid, date, employeeID, uid)
var recordView interface{}
if record != nil {
recordView = oaAttendView(record)
}
canClockIn := record == nil || record.ClockInTime == nil
canClockOut := record == nil || record.ClockOutTime == nil
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).Format("2006-01-02")
c.oaOk(map[string]interface{}{
"date": date,
"now": now.Format("2006-01-02 15:04:05"),
"employee": oaAttendEmployeeMap(tid, employee, fallbackName),
"entry": oaAttendEntryMap(entry),
"record": recordView,
"matched": entry != nil,
"warning": oaAttendTodayWarning(entry, employee),
"can_clock_in": canClockIn,
"can_clock_out": canClockOut,
"recent": oaAttendRecentDays(tid, uid, employeeID, date, days),
"stats": oaAttendPersonalStats(tid, uid, employeeID, monthStart, date),
})
}
// AttendanceClock POST /api/oa/attendance/clock
// body: {"type":"in|out","location":"...","remark":"..."}
// 未匹配排班时会返回 warning,但打卡依然成功(match_status=2,clock_*_status=3)。
func (c *AppOaController) AttendanceClock() {
claims, code, msg := c.oaAttendAppClaims()
if msg != "" {
c.oaErr(code, code, msg)
return
}
var payload struct {
Type string `json:"type"`
Location string `json:"location"`
Remark string `json:"remark"`
}
if raw, readErr := io.ReadAll(c.Ctx.Request.Body); readErr == nil && len(raw) > 0 {
if json.Unmarshal(raw, &payload) != nil {
c.oaErr(400, 400, "参数错误")
return
}
}
employee, fallbackName := c.oaAttendSelf(claims)
result, code, msg := oaAttendPunch(uint64(claims.TenantId), uint64(claims.UserID), employee, oaAttendPunchInput{
Type: payload.Type,
Location: strings.TrimSpace(payload.Location),
Remark: strings.TrimSpace(payload.Remark),
FallbackName: fallbackName,
})
if msg != "" {
c.oaErr(code, code, msg)
return
}
c.oaOk(map[string]interface{}{
"record": result.View,
"warning": result.Warning,
"duplicated": result.Duplicated,
"message": result.Message,
})
}
// AttendanceRecords GET /api/oa/attendance/records?page=&pageSize=&start_date=&end_date=
// 本人考勤记录(默认本月),返回 list/total/stats。
func (c *AppOaController) AttendanceRecords() {
claims, code, msg := c.oaAttendAppClaims()
if msg != "" {
c.oaErr(code, code, msg)
return
}
tid := uint64(claims.TenantId)
uid := uint64(claims.UserID)
page, _ := c.GetInt("page", 1)
pageSize, _ := c.GetInt("pageSize", 20)
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
now := time.Now()
startDate := strings.TrimSpace(c.GetString("start_date"))
endDate := strings.TrimSpace(c.GetString("end_date"))
if startDate == "" || endDate == "" {
startDate = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).Format("2006-01-02")
endDate = now.Format("2006-01-02")
}
dates, msg := scDateSpan(startDate, endDate)
if msg != "" {
c.oaErr(400, 400, msg)
return
}
if len(dates) > oaAttendMaxRangeDays {
c.oaErr(400, 400, "单次最多查询 92 天")
return
}
employee, _ := c.oaAttendSelf(claims)
employeeID := uint(0)
if employee != nil {
employeeID = employee.ID
}
cond := orm.NewCondition().
And("tid", tid).
And("attend_date__gte", startDate).
And("attend_date__lte", endDate)
if employeeID > 0 {
cond = cond.And("employee_id", employeeID)
} else {
cond = cond.And("uid", uid)
}
qs := models.Orm.QueryTable(new(models.OaAttendance)).SetCond(cond)
total, _ := qs.Count()
var rows []models.OaAttendance
_, queryErr := qs.OrderBy("-attend_date", "id").
Limit(pageSize).Offset((page - 1) * pageSize).All(&rows)
if queryErr != nil && queryErr != orm.ErrNoRows {
c.oaErr(500, 500, "查询考勤记录失败")
return
}
list := make([]oaAttendanceView, 0, len(rows))
for i := range rows {
list = append(list, oaAttendView(&rows[i]))
}
c.oaOk(map[string]interface{}{
"list": list,
"total": total,
"start_date": startDate,
"end_date": endDate,
"stats": oaAttendPersonalStats(tid, uid, employeeID, startDate, endDate),
})
}
// AttendanceStats GET /api/oa/attendance/stats?period=month|quarter|year&date=YYYY-MM-DD
// 个人考勤卡:按「月 / 季 / 年」聚合,供移动端统计卡片切换维度。
func (c *AppOaController) AttendanceStats() {
claims, code, msg := c.oaAttendAppClaims()
if msg != "" {
c.oaErr(code, code, msg)
return
}
tid := uint64(claims.TenantId)
uid := uint64(claims.UserID)
period := strings.ToLower(strings.TrimSpace(c.GetString("period")))
if period != "month" && period != "quarter" && period != "year" {
period = "month"
}
anchor := strings.TrimSpace(c.GetString("date"))
if !isValidOaDate(anchor) {
anchor = time.Now().Format("2006-01-02")
}
startDate, endDate, label, rangeOK := oaAttendPeriodRange(period, anchor)
if !rangeOK {
c.oaErr(400, 400, "日期参数无效")
return
}
endDate = oaAttendClampRange(startDate, endDate)
employee, _ := c.oaAttendSelf(claims)
employeeID := uint(0)
if employee != nil {
employeeID = employee.ID
}
c.oaOk(map[string]interface{}{
"period": period,
"label": label,
"anchor": anchor,
"start_date": startDate,
"end_date": endDate,
"stats": oaAttendPersonalStats(tid, uid, employeeID, startDate, endDate),
})
}
+34 -10
View File
@@ -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})
+99 -17
View File
@@ -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"}}
+919
View File
@@ -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)
}
}
+15 -5
View File
@@ -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).
+79
View File
@@ -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"`
+68
View File
@@ -0,0 +1,68 @@
-- =============================================================
-- OA 考勤打卡 —— 表结构
--
-- 对应代码:
-- models/oa_attendance.go
-- controllers/backend_oa_attendance.go (打卡核心 + 后台考勤记录)
-- controllers/app_oa_attendance.go (移动端 /api/oa/attendance/*)
--
-- 执行顺序:
-- 1) 本脚本:创建考勤表
-- 2) oa_attendance_menus.sql:注册「OA → 排班考勤 → 考勤记录 / 考勤打卡」菜单
--
-- 设计要点:
-- 1. 一人一天一条:签到与签退写同一行,统计与列表都按「人 × 日期」聚合。
-- 2. 打卡时联动排班明细(yz_backend_oa_schedule_entry),把班次名称与
-- 上下班时间快照进本表;后续调班不影响历史考勤判定。
-- 3. 匹配不上排班(含员工档案缺失)不阻断打卡:match_status=2,
-- clock_in_status/clock_out_status=3(无排班打卡),提醒文案落在
-- clock_in_note / clock_out_note,后台可据此筛选与备注。
-- 4. 唯一键 (tid, uid, attend_date):uid 是登录身份,保证员工档案未建
-- 也能先打卡(此时 employee_id=0,后台按账号/姓名检索)。
-- =============================================================
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS `yz_backend_oa_attendance` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID',
`uid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '登录身份ID(打卡主体)',
`employee_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '员工ID(yz_backend_employee.id),0=未绑定员工档案',
`employee_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '员工姓名快照',
`employee_account` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '员工账号快照',
`org_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '所属组织ID',
`org_name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '组织名称快照',
`position` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '职位快照',
`attend_date` DATE NOT NULL COMMENT '考勤日期',
`entry_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '排班明细ID,0=未匹配排班',
`shift_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '班次ID快照',
`shift_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '班次名称快照',
`short_name` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '班次简称快照',
`color` VARCHAR(20) NOT NULL DEFAULT '#409EFF' COMMENT '班次颜色快照',
`work_start` VARCHAR(8) NOT NULL DEFAULT '' COMMENT '上班时间快照 HH:mm',
`work_end` VARCHAR(8) NOT NULL DEFAULT '' COMMENT '下班时间快照 HH:mm',
`match_status` TINYINT NOT NULL DEFAULT 0 COMMENT '0=未知 1=已匹配排班 2=未匹配排班',
`clock_in_time` DATETIME DEFAULT NULL COMMENT '签到时间',
`clock_in_status` TINYINT NOT NULL DEFAULT 0 COMMENT '0=未打卡 1=正常 2=迟到 3=无排班打卡 4=不在班次时段',
`clock_in_note` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '签到提醒/异常说明',
`clock_out_time` DATETIME DEFAULT NULL COMMENT '签退时间',
`clock_out_status` TINYINT NOT NULL DEFAULT 0 COMMENT '0=未打卡 1=正常 2=早退 3=无排班打卡 4=不在班次时段',
`clock_out_note` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '签退提醒/异常说明',
`location` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '打卡地点(预留)',
`remark` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '管理员备注/异常说明',
`source` TINYINT NOT NULL DEFAULT 1 COMMENT '1=移动端打卡 2=后台补录',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tid_uid_date` (`tid`, `uid`, `attend_date`),
KEY `idx_tid_date` (`tid`, `attend_date`),
KEY `idx_tid_emp_date` (`tid`, `employee_id`, `attend_date`),
KEY `idx_tid_org_date` (`tid`, `org_id`, `attend_date`),
KEY `idx_tid_status` (`tid`, `clock_in_status`, `clock_out_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OA-考勤打卡记录';
-- ---------------- 校验 ----------------
SELECT TABLE_NAME, TABLE_COMMENT
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'yz_backend_oa_attendance';
+223
View File
@@ -0,0 +1,223 @@
-- =============================================================================
-- 人员管理(yz_backend_employee) 与 用户管理(yz_system_tenant_user) 老数据对齐
--
-- 背景:员工表早期建的是「账号全局唯一」索引 uk_account(account),多租户后同一账号
-- 在不同企业只能存在一份员工档案;再叠加历史手工录入的人员(如 hero001 这类旧账号),
-- 导致「OA 设置 → 人员管理」与「基本设置 → 用户管理」两边数据对不上。
--
-- 处理内容:
-- 1. 索引调整为 (tid, account) 唯一 ← 必须最先执行,否则第 3 步会撞全局唯一键
-- 2. 员工 tid 为空/0:按唯一账号补 tid
-- 3. 同一企业内「用户缺员工 + 存在同名无账号老员工」:把老员工账号改成用户账号
-- (保留员工 ID,排班 / 日报 / 人事档案的历史引用不受影响)
-- 4. 用户仍然没有员工:按用户资料补建员工记录(与「用户管理 → 同步员工」同规则)
-- 5. 复核查询 + 仍需人工确认的清单
--
-- 反向缺口(「人员管理有、用户管理没有」)不在这里处理:补账号要先在统一认证建身份
-- 并生成 argon2id 密码哈希,SQL 做不到,用 Go 工具:
-- go run scripts/syncemployeeusers/main.go -tid <企业ID> -apply
--
-- 特性:可重复执行(幂等),第 2 次执行不会产生任何变更。
-- 执行前请务必备份:mysqldump -u root -p go-platform > backup_$(date +%F).sql
-- 执行后请重启 backend 服务:services/employee_sync.go 已改为按 (tid, account) 匹配员工。
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 0. 执行前预检(只看不改)
-- -----------------------------------------------------------------------------
SELECT '执行前:用户/员工数量' AS section;
SELECT
(SELECT COUNT(*) FROM yz_system_tenant_user WHERE delete_time IS NULL) AS user_rows,
(SELECT COUNT(*) FROM yz_backend_employee WHERE delete_time IS NULL) AS employee_rows,
(SELECT COUNT(*) FROM yz_backend_employee WHERE delete_time IS NULL AND (tid IS NULL OR tid = 0)) AS employee_tid_empty,
(SELECT COUNT(*) FROM yz_system_tenant_user u
WHERE u.delete_time IS NULL AND u.account IS NOT NULL AND TRIM(u.account) <> ''
AND NOT EXISTS (SELECT 1 FROM yz_backend_employee e
WHERE e.delete_time IS NULL AND e.tid = u.tid AND TRIM(e.account) = TRIM(u.account))
) AS user_without_employee;
-- 若下面这条 > 0,需要先人工处理重复的 (tid, account),否则第 1 步加唯一索引会失败
SELECT '重复的 (tid, account)' AS section;
SELECT tid, TRIM(account) AS account, COUNT(*) AS cnt
FROM yz_backend_employee
WHERE delete_time IS NULL
GROUP BY tid, TRIM(account)
HAVING COUNT(*) > 1;
-- -----------------------------------------------------------------------------
-- 1. 索引:账号全局唯一 → 企业内唯一
-- 关键:不先放开全局唯一,第 3 步把 hero001 改成 hero920103 会因另一企业已占用而失败
-- -----------------------------------------------------------------------------
SET @has_old := (SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_employee'
AND INDEX_NAME = 'uk_account');
SET @sql := IF(@has_old > 0, 'ALTER TABLE yz_backend_employee DROP INDEX uk_account', 'DO 0');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @has_plain := (SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_employee'
AND INDEX_NAME = 'idx_tid_account');
SET @sql := IF(@has_plain > 0, 'ALTER TABLE yz_backend_employee DROP INDEX idx_tid_account', 'DO 0');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @has_uk := (SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_employee'
AND INDEX_NAME = 'uk_tid_account' AND NON_UNIQUE = 0);
SET @sql := IF(@has_uk = 0,
'ALTER TABLE yz_backend_employee ADD UNIQUE KEY uk_tid_account (tid, account)',
'DO 0');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- -----------------------------------------------------------------------------
-- 2. 员工 tid 为空/0:按账号唯一匹配到企业用户后补 tid
-- -----------------------------------------------------------------------------
UPDATE yz_backend_employee e
JOIN (
SELECT TRIM(account) AS account, MIN(tid) AS tid
FROM yz_system_tenant_user
WHERE delete_time IS NULL AND account IS NOT NULL AND TRIM(account) <> ''
GROUP BY TRIM(account)
HAVING COUNT(DISTINCT tid) = 1
) u ON u.account = TRIM(e.account)
SET e.tid = u.tid,
e.update_time = NOW()
WHERE e.delete_time IS NULL
AND (e.tid IS NULL OR e.tid = 0);
-- -----------------------------------------------------------------------------
-- 3. 同企业内同名老员工账号对齐
-- 条件:① 员工账号在本企业没有对应用户(历史手工人员)
-- ② 该用户在本企业还没有员工
-- ③ 按姓名能唯一匹配到一个用户
-- 只改 account,不动 id,历史引用全部保留
-- -----------------------------------------------------------------------------
DROP TEMPORARY TABLE IF EXISTS tmp_emp_align;
CREATE TEMPORARY TABLE tmp_emp_align (
emp_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
tid BIGINT NOT NULL,
old_account VARCHAR(64) NOT NULL,
new_account VARCHAR(64) NOT NULL
) ENGINE = InnoDB;
INSERT INTO tmp_emp_align (emp_id, tid, old_account, new_account)
SELECT e.id, e.tid, TRIM(e.account), MIN(TRIM(u.account))
FROM yz_backend_employee e
JOIN yz_system_tenant_user u
ON u.tid = e.tid
AND u.delete_time IS NULL
AND u.account IS NOT NULL
AND TRIM(u.account) <> ''
AND TRIM(u.name) = TRIM(e.name)
LEFT JOIN yz_system_tenant_user ux
ON ux.tid = e.tid
AND ux.delete_time IS NULL
AND TRIM(ux.account) = TRIM(e.account)
LEFT JOIN yz_backend_employee ex
ON ex.tid = e.tid
AND ex.delete_time IS NULL
AND TRIM(ex.account) = TRIM(u.account)
WHERE e.delete_time IS NULL
AND e.tid IS NOT NULL
AND e.tid > 0
AND ux.id IS NULL -- 员工账号在本企业没有用户
AND ex.id IS NULL -- 用户在本企业还没有员工
GROUP BY e.id, e.tid, TRIM(e.account)
HAVING COUNT(DISTINCT TRIM(u.account)) = 1; -- 同名用户不唯一时不动,留待人工
UPDATE yz_backend_employee e
JOIN tmp_emp_align t ON t.emp_id = e.id
SET e.account = t.new_account,
e.update_time = NOW();
SELECT '第 3 步:对齐的老员工账号' AS section, emp_id, tid, old_account, new_account
FROM tmp_emp_align;
DROP TEMPORARY TABLE IF EXISTS tmp_emp_align;
-- -----------------------------------------------------------------------------
-- 4. 用户仍然没有员工:按用户资料补建员工记录
-- 与 services/EnsureEmployeeForTenantUser 的规则一致(账号、姓名、性别、生日、
-- 部门、手机、邮箱、启用状态),密码留空(登录走统一认证,不校验员工密码)
-- -----------------------------------------------------------------------------
INSERT INTO yz_backend_employee
(tid, account, password, name, gender, birthday, department, phone, email,
account_status, create_time, update_time)
SELECT
u.tid,
TRIM(u.account),
'',
COALESCE(NULLIF(TRIM(u.name), ''), TRIM(u.account)),
IFNULL(u.sex, 0),
STR_TO_DATE(LEFT(TRIM(IFNULL(u.birth, '')), 10), '%Y-%m-%d'),
CASE WHEN IFNULL(u.org_id, 0) > 0 THEN CAST(u.org_id AS CHAR) ELSE NULL END,
NULLIF(TRIM(IFNULL(u.phone, '')), ''),
NULLIF(TRIM(IFNULL(u.email, '')), ''),
IF(IFNULL(u.status, 1) = 0, 0, 1),
NOW(), NOW()
FROM yz_system_tenant_user u
LEFT JOIN yz_backend_employee e
ON e.delete_time IS NULL
AND e.tid = u.tid
AND TRIM(e.account) = TRIM(u.account)
WHERE u.delete_time IS NULL
AND u.account IS NOT NULL
AND TRIM(u.account) <> ''
AND e.id IS NULL;
-- -----------------------------------------------------------------------------
-- 5. 复核
-- -----------------------------------------------------------------------------
SELECT '执行后:用户/员工数量' AS section;
SELECT
(SELECT COUNT(*) FROM yz_system_tenant_user WHERE delete_time IS NULL) AS user_rows,
(SELECT COUNT(*) FROM yz_backend_employee WHERE delete_time IS NULL) AS employee_rows,
(SELECT COUNT(*) FROM yz_backend_employee WHERE delete_time IS NULL AND (tid IS NULL OR tid = 0)) AS employee_tid_empty,
(SELECT COUNT(*) FROM yz_system_tenant_user u
WHERE u.delete_time IS NULL AND u.account IS NOT NULL AND TRIM(u.account) <> ''
AND NOT EXISTS (SELECT 1 FROM yz_backend_employee e
WHERE e.delete_time IS NULL AND e.tid = u.tid AND TRIM(e.account) = TRIM(u.account))
) AS user_without_employee;
-- 按企业对比:用户数 / 员工数 / 本企业无账号的员工数
SELECT
u.tid,
u.user_cnt,
e.emp_cnt,
e.emp_without_user
FROM (
SELECT tid, COUNT(*) AS user_cnt
FROM yz_system_tenant_user
WHERE delete_time IS NULL
GROUP BY tid
) u
LEFT JOIN (
SELECT x.tid,
COUNT(*) AS emp_cnt,
SUM(CASE WHEN ux.id IS NULL THEN 1 ELSE 0 END) AS emp_without_user
FROM yz_backend_employee x
LEFT JOIN yz_system_tenant_user ux
ON ux.tid = x.tid
AND ux.delete_time IS NULL
AND TRIM(ux.account) = TRIM(x.account)
WHERE x.delete_time IS NULL
GROUP BY x.tid
) e ON e.tid = u.tid
ORDER BY u.tid;
-- -----------------------------------------------------------------------------
-- 6. 仍需人工确认:员工在本企业没有登录账号(老数据,脚本不删除也不改)
-- 处理方式二选一:
-- a) 在「用户管理」用相同账号添加用户 → 会自动关联到已有员工档案,不会重复建档
-- b) 在「人员管理」把这些人员设为离职(account_status = 2)
-- -----------------------------------------------------------------------------
SELECT '仍无登录账号的员工' AS section,
e.tid, e.id, TRIM(e.account) AS account, TRIM(e.name) AS name,
TRIM(IFNULL(e.department, '')) AS department
FROM yz_backend_employee e
LEFT JOIN yz_system_tenant_user u
ON u.tid = e.tid
AND u.delete_time IS NULL
AND TRIM(u.account) = TRIM(e.account)
WHERE e.delete_time IS NULL
AND u.id IS NULL
ORDER BY e.tid, e.id;
+122
View File
@@ -0,0 +1,122 @@
-- ============================================================
-- OA AI 办公 —— 菜单配置(yz_system_menu)
-- 幂等脚本:按 path 判重,重复执行不会产生重复菜单
-- 执行:mysql -h <host> -P <port> -u <user> -p <db> < oa_ai_menus.sql
--
-- 挂载位置:
-- OA → 办公 → AI办公(页面,/apps/oa/ai)
-- OA → 基础设置 → AI设置(页面,/basic-settings/ai)
--
-- 对应页面:
-- backend/src/views/apps/oa/ai/index.vue AI 广场(场景对话)
-- backend/src/views/basicSettings/ai/index.vue AI 设置(租户级接口 + Key)
--
-- 说明:
-- 1. AI 接入配置是租户级的(yz_backend_ai_provider.user_id = 0),
-- 由「AI设置」页统一维护,保存后全租户共用;
-- 2. 「AI办公」是 Web 页面(platforms=["web"]),移动端暂不提供;
-- 3. 租户端角色(cid=2)的 rights 是显式菜单 ID 列表,只插菜单不授权
-- 会被过滤掉,因此本脚本把菜单 ID 追加到角色 rights。
-- ============================================================
SET @oa_id := (
SELECT id FROM yz_system_menu
WHERE path = '/apps/oa' AND delete_time IS NULL
LIMIT 1
);
-- 「办公」目录:优先同名菜单,其次 /apps/oa/personal;都没有则回落 OA 根
SET @office_id := (
SELECT id FROM yz_system_menu
WHERE delete_time IS NULL
AND type = 1
AND (title = '办公' OR title = '个人办公' OR path = '/apps/oa/personal')
ORDER BY (title = '办公') DESC, (path = '/apps/oa/personal') DESC, sort, id
LIMIT 1
);
-- OA 基础设置目录(AI 设置挂这里)
SET @basic_id := (
SELECT id FROM yz_system_menu
WHERE path = '/apps/oa/normalsetting' AND delete_time IS NULL
LIMIT 1
);
SET @office_parent := IFNULL(@office_id, @oa_id);
SET @basic_parent := IFNULL(@basic_id, @office_parent);
-- ---------- AI办公(页面,挂在办公下) ----------
INSERT INTO yz_system_menu
(pid, title, path, component_path, icon, sort, status, is_visible, views, platforms, type, creater, remark)
SELECT @office_parent, 'AI办公', '/apps/oa/ai', '/apps/oa/ai/index.vue',
'MagicStick', 91, 1, 1, '[2]', '["web"]', 2, 'system',
'AI 广场:选择接入与模型进行场景对话,支持图片输入、系统提示词与多会话管理'
FROM DUAL
WHERE @office_parent IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/apps/oa/ai' AND delete_time IS NULL) x);
SET @ai_page_id := (
SELECT id FROM yz_system_menu
WHERE path = '/apps/oa/ai' AND delete_time IS NULL
LIMIT 1
);
UPDATE yz_system_menu
SET pid = IFNULL(@office_parent, pid),
sort = 91,
platforms = '["web"]',
is_visible = 1
WHERE path = '/apps/oa/ai' AND delete_time IS NULL;
-- ---------- AI设置(页面,挂在 OA 基础设置下) ----------
INSERT INTO yz_system_menu
(pid, title, path, component_path, icon, sort, status, is_visible, views, platforms, type, creater, remark)
SELECT @basic_parent, 'AI设置', '/basic-settings/ai', '/basicSettings/ai/index.vue',
'Setting', 9, 1, 1, '[2]', '["web"]', 2, 'system',
'租户级 AI 接入配置:填写接口地址与 API Key,支持 OpenAI 兼容与 Anthropic,可测试连通性'
FROM DUAL
WHERE @basic_parent IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/basic-settings/ai' AND delete_time IS NULL) x);
SET @ai_settings_id := (
SELECT id FROM yz_system_menu
WHERE path = '/basic-settings/ai' AND delete_time IS NULL
LIMIT 1
);
UPDATE yz_system_menu
SET pid = IFNULL(@basic_parent, pid),
sort = 9,
platforms = '["web"]',
is_visible = 1
WHERE path = '/basic-settings/ai' AND delete_time IS NULL;
-- ---------- 角色授权:把菜单追加到所有租户端角色的 rights ----------
-- 只处理 rights 非空(显式授权)的角色;rights 为空表示全权限,无需追加。
UPDATE yz_system_admin_role
SET rights = CAST(JSON_ARRAY_APPEND(CAST(rights AS JSON), '$', @ai_page_id) AS CHAR)
WHERE cid = 2
AND rights IS NOT NULL AND TRIM(rights) <> '' AND JSON_VALID(rights)
AND @ai_page_id IS NOT NULL
AND NOT JSON_CONTAINS(CAST(rights AS JSON), CAST(@ai_page_id AS JSON));
UPDATE yz_system_admin_role
SET rights = CAST(JSON_ARRAY_APPEND(CAST(rights AS JSON), '$', @ai_settings_id) AS CHAR)
WHERE cid = 2
AND rights IS NOT NULL AND TRIM(rights) <> '' AND JSON_VALID(rights)
AND @ai_settings_id IS NOT NULL
AND NOT JSON_CONTAINS(CAST(rights AS JSON), CAST(@ai_settings_id AS JSON));
-- ---------- 结果校验 ----------
SELECT id, pid, title, path, component_path, platforms, type, sort, is_visible
FROM yz_system_menu
WHERE delete_time IS NULL
AND (path = '/apps/oa/ai' OR path = '/basic-settings/ai')
ORDER BY pid, sort;
SELECT id, name, tenant_id,
JSON_CONTAINS(CAST(rights AS JSON), CAST(@ai_page_id AS JSON)) AS has_ai_page,
JSON_CONTAINS(CAST(rights AS JSON), CAST(@ai_settings_id AS JSON)) AS has_ai_settings
FROM yz_system_admin_role
WHERE cid = 2 AND rights IS NOT NULL AND TRIM(rights) <> ''
ORDER BY tenant_id, id;
+160
View File
@@ -0,0 +1,160 @@
-- ============================================================
-- OA 考勤管理 —— 菜单配置(yz_system_menu)
-- 幂等脚本:按 path 判重,重复执行不会产生重复菜单
-- 执行:mysql -h <host> -P <port> -u <user> -p <db> < oa_attendance_menus.sql
--
-- 挂载位置:OA → 办公(个人办公),直接是页面,不再套「排班考勤」目录
-- OA
-- └─ 办公(/apps/oa/personal,找不到时回落到 OA 根)
-- ├─ 工作排班(Web,/apps/oa/scheduling/board)
-- ├─ 考勤管理(Web,/apps/oa/attendance) ← 本脚本
-- └─ 考勤打卡(App,/apps/oa/attendance/clock) ← 本脚本
--
-- 页面:
-- backend/src/views/apps/oa/attendance/index.vue 考勤管理(考勤汇总 + 考勤明细)
-- uniapp/pages/oa/attendance/index.vue 考勤打卡(移动端)
--
-- 说明:
-- 1. 「考勤管理」是直接页面(type=2),点菜单即打开,不再有子菜单;
-- 2. 打卡动作在移动端完成,Web 端只做考勤汇总 / 明细 / 缺卡名单 / 备注,
-- 因此「考勤管理」platforms 为 ["web"];
-- 3. 「考勤打卡」platforms 为 ["app"],通过 uniapp 菜单跳转原生页面
-- (uniapp/utils/nav.js 的 NATIVE_RULES 按「考勤」匹配);
-- 4. 历史版本创建的「排班考勤」目录不再使用,脚本会把它隐藏(保留数据);
-- 其下的「工作排班」直接上移到「办公」;
-- 5. 租户端角色(cid=2)的 rights 是显式菜单 ID 列表,只插菜单不授权
-- 会被过滤掉,因此本脚本把菜单 ID 追加到角色 rights。
-- ============================================================
SET @oa_id := (
SELECT id FROM yz_system_menu
WHERE path = '/apps/oa' AND delete_time IS NULL
LIMIT 1
);
-- 「办公」目录:优先同名菜单,其次 /apps/oa/personal;都没有则回落 OA 根
SET @office_id := (
SELECT id FROM yz_system_menu
WHERE delete_time IS NULL
AND type = 1
AND (title = '办公' OR title = '个人办公' OR path = '/apps/oa/personal')
ORDER BY (title = '办公') DESC, (path = '/apps/oa/personal') DESC, sort, id
LIMIT 1
);
SET @parent_id := IFNULL(@office_id, @oa_id);
-- ---------- 考勤管理(Web 页面,直接挂在办公下) ----------
INSERT INTO yz_system_menu
(pid, title, path, component_path, icon, sort, status, is_visible, views, platforms, type, creater, remark)
SELECT @parent_id, '考勤管理', '/apps/oa/attendance', '/apps/oa/attendance/index.vue',
'Stopwatch', 89, 1, 1, '[2]', '["web"]', 2, 'system',
'移动端签到签退数据,支持月/季/年日历汇总、全员考勤卡、缺卡名单与备注'
FROM DUAL
WHERE @parent_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/apps/oa/attendance' AND delete_time IS NULL) x);
-- 已存在则纠正为「直接页面」:改名、上移、确保是页面与 Web 端可见
UPDATE yz_system_menu
SET title = '考勤管理',
pid = IFNULL(@parent_id, pid),
sort = 89,
type = 2,
component_path = '/apps/oa/attendance/index.vue',
platforms = '["web"]',
is_visible = 1
WHERE path = '/apps/oa/attendance' AND delete_time IS NULL;
SET @records_id := (
SELECT id FROM yz_system_menu
WHERE path = '/apps/oa/attendance' AND delete_time IS NULL
LIMIT 1
);
-- ---------- 考勤打卡(移动端页面,同样直接挂在办公下) ----------
INSERT INTO yz_system_menu
(pid, title, path, component_path, icon, sort, status, is_visible, views, platforms, type, creater, remark)
SELECT @parent_id, '考勤打卡', '/apps/oa/attendance/clock', NULL,
'AlarmClock', 90, 1, 1, '[2]', '["app"]', 2, 'system',
'小程序签到/签退,联动工作排班;未匹配排班时提醒但允许打卡'
FROM DUAL
WHERE @parent_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/apps/oa/attendance/clock' AND delete_time IS NULL) x);
UPDATE yz_system_menu
SET pid = IFNULL(@parent_id, pid),
sort = 90,
platforms = '["app"]',
is_visible = 1
WHERE path = '/apps/oa/attendance/clock' AND delete_time IS NULL;
SET @clock_id := (
SELECT id FROM yz_system_menu
WHERE path = '/apps/oa/attendance/clock' AND delete_time IS NULL
LIMIT 1
);
-- ---------- 工作排班上移:不再保留「排班考勤」目录 ----------
UPDATE yz_system_menu
SET pid = IFNULL(@parent_id, pid), sort = 88
WHERE path = '/apps/oa/scheduling/board'
AND delete_time IS NULL
AND @parent_id IS NOT NULL
AND (pid IS NULL OR pid <> @parent_id);
-- 历史的「排班考勤」目录:不再使用,隐藏(保留数据,避免影响已有角色授权)
-- 仅当目录下已无可见子菜单时才隐藏,避免误伤租户自行添加的子菜单
UPDATE yz_system_menu
SET is_visible = 0
WHERE path = '/apps/oa/scheduling'
AND type = 1
AND delete_time IS NULL
AND id NOT IN (
SELECT pid FROM (
SELECT pid FROM yz_system_menu
WHERE delete_time IS NULL AND is_visible = 1 AND pid > 0
) child
);
SET @board_id := (
SELECT id FROM yz_system_menu
WHERE path = '/apps/oa/scheduling/board' AND delete_time IS NULL
LIMIT 1
);
-- ---------- 角色授权:把菜单追加到所有租户端角色的 rights ----------
-- 只处理 rights 非空(显式授权)的角色;rights 为空表示全权限,无需追加。
UPDATE yz_system_admin_role
SET rights = CAST(JSON_ARRAY_APPEND(CAST(rights AS JSON), '$', @records_id) AS CHAR)
WHERE cid = 2
AND rights IS NOT NULL AND TRIM(rights) <> '' AND JSON_VALID(rights)
AND @records_id IS NOT NULL
AND NOT JSON_CONTAINS(CAST(rights AS JSON), CAST(@records_id AS JSON));
UPDATE yz_system_admin_role
SET rights = CAST(JSON_ARRAY_APPEND(CAST(rights AS JSON), '$', @clock_id) AS CHAR)
WHERE cid = 2
AND rights IS NOT NULL AND TRIM(rights) <> '' AND JSON_VALID(rights)
AND @clock_id IS NOT NULL
AND NOT JSON_CONTAINS(CAST(rights AS JSON), CAST(@clock_id AS JSON));
UPDATE yz_system_admin_role
SET rights = CAST(JSON_ARRAY_APPEND(CAST(rights AS JSON), '$', @board_id) AS CHAR)
WHERE cid = 2
AND rights IS NOT NULL AND TRIM(rights) <> '' AND JSON_VALID(rights)
AND @board_id IS NOT NULL
AND NOT JSON_CONTAINS(CAST(rights AS JSON), CAST(@board_id AS JSON));
-- ---------- 结果校验 ----------
SELECT id, pid, title, path, component_path, platforms, type, sort, is_visible
FROM yz_system_menu
WHERE delete_time IS NULL
AND (path LIKE '/apps/oa/attendance%' OR path LIKE '/apps/oa/scheduling%')
ORDER BY pid, sort;
SELECT id, name, tenant_id,
JSON_CONTAINS(CAST(rights AS JSON), CAST(@records_id AS JSON)) AS has_attendance,
JSON_CONTAINS(CAST(rights AS JSON), CAST(@clock_id AS JSON)) AS has_clock
FROM yz_system_admin_role
WHERE cid = 2 AND rights IS NOT NULL AND TRIM(rights) <> ''
ORDER BY tenant_id, id;
+48 -37
View File
@@ -3,10 +3,12 @@
-- 幂等脚本:按 path 判重,重复执行不会产生重复菜单
-- 执行:mysql -h <host> -P <port> -u <user> -p <db> < oa_scheduling_menus.sql
--
-- 挂载位置:OA 应用根(path = '/apps/oa')
-- 挂载位置:OA → 办公(个人办公),直接是页面,不再套「排班考勤」目录
-- OA
-- └─ 排班考勤(目录,/apps/oa/scheduling)
-- └─ 工作排班(页面,/apps/oa/scheduling/board)
-- └─ 办公
-- ├─ 工作排班(页面,/apps/oa/scheduling/board) ← 本脚本
-- ├─ 考勤管理(页面,/apps/oa/attendance) ← oa_attendance_menus.sql
-- └─ 考勤打卡(App,/apps/oa/attendance/clock) ← oa_attendance_menus.sql
--
-- 对应页面:backend/src/views/apps/oa/scheduling/index.vue
-- 同一页面内完成:班次管理、排班方案(公司/部门/小组/指定员工)、
@@ -14,11 +16,11 @@
--
-- 说明:
-- 1. 排班看板是 Web 管理端功能,platforms 显式为 ["web"],
-- 移动端菜单不出现(后续若开放员工查看排班,再补 APP 端页面)。
-- 2. 租户端角色(cid=2)的 rights 是显式菜单 ID 列表,菜单接口会按
-- rights 过滤。只插菜单不授权,会出现「目录可见但页面被过滤」,
-- 因此本脚本同时把两个菜单 ID 追加到角色 rights。
-- 目录与页面都必须授权,缺一不可。
-- 移动端菜单不出现;
-- 2. 历史版本创建的「排班考勤」目录不再使用,脚本会把它隐藏(保留数据);
-- 3. 租户端角色(cid=2)的 rights 是显式菜单 ID 列表,菜单接口会按
-- rights 过滤。只插菜单不授权,会出现「菜单存在但被过滤」,
-- 因此本脚本同时把菜单 ID 追加到角色 rights。
-- ============================================================
SET @oa_id := (
@@ -27,32 +29,51 @@ SET @oa_id := (
LIMIT 1
);
-- ---------- 排班考勤(目录) ----------
INSERT INTO yz_system_menu
(pid, title, path, component_path, icon, sort, status, is_visible, views, platforms, type, creater, remark)
SELECT @oa_id, '排班考勤', '/apps/oa/scheduling', NULL,
'Calendar', 88, 1, 1, '[2]', '["web"]', 1, 'system',
'按公司、部门、小组或指定员工制定排班方案,支持临时调配'
FROM DUAL
WHERE @oa_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/apps/oa/scheduling' AND delete_time IS NULL) x);
SET @scheduling_id := (
-- 「办公」目录:优先同名菜单,其次 /apps/oa/personal;都没有则回落 OA 根
SET @office_id := (
SELECT id FROM yz_system_menu
WHERE path = '/apps/oa/scheduling' AND delete_time IS NULL
WHERE delete_time IS NULL
AND type = 1
AND (title = '办公' OR title = '个人办公' OR path = '/apps/oa/personal')
ORDER BY (title = '办公') DESC, (path = '/apps/oa/personal') DESC, sort, id
LIMIT 1
);
-- ---------- 工作排班(页面) ----------
SET @parent_id := IFNULL(@office_id, @oa_id);
-- ---------- 工作排班(页面,直接挂在办公下) ----------
INSERT INTO yz_system_menu
(pid, title, path, component_path, icon, sort, status, is_visible, views, platforms, type, creater, remark)
SELECT @scheduling_id, '工作排班', '/apps/oa/scheduling/board', '/apps/oa/scheduling/index.vue',
'Clock', 1, 1, 1, '[2]', '["web"]', 2, 'system',
SELECT @parent_id, '工作排班', '/apps/oa/scheduling/board', '/apps/oa/scheduling/index.vue',
'Clock', 88, 1, 1, '[2]', '["web"]', 2, 'system',
'排班看板:按周查看排班,支持方案发布、单条调班与批量临时排班'
FROM DUAL
WHERE @scheduling_id IS NOT NULL
WHERE @parent_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/apps/oa/scheduling/board' AND delete_time IS NULL) x);
-- 已存在则上移到「办公」,并纠正为 Web 页面
UPDATE yz_system_menu
SET pid = IFNULL(@parent_id, pid),
sort = 88,
type = 2,
platforms = '["web"]',
is_visible = 1
WHERE path = '/apps/oa/scheduling/board' AND delete_time IS NULL;
-- 历史的「排班考勤」目录:不再使用,隐藏(保留数据)
-- 仅当目录下已无可见子菜单时才隐藏,避免误伤租户自行添加的子菜单
UPDATE yz_system_menu
SET is_visible = 0
WHERE path = '/apps/oa/scheduling'
AND type = 1
AND delete_time IS NULL
AND id NOT IN (
SELECT pid FROM (
SELECT pid FROM yz_system_menu
WHERE delete_time IS NULL AND is_visible = 1 AND pid > 0
) child
);
SET @board_id := (
SELECT id FROM yz_system_menu
WHERE path = '/apps/oa/scheduling/board' AND delete_time IS NULL
@@ -61,16 +82,6 @@ SET @board_id := (
-- ---------- 角色授权:把菜单追加到所有租户端角色的 rights ----------
-- 只处理 rights 非空(显式授权)的角色;rights 为空表示全权限,无需追加。
UPDATE yz_system_admin_role
SET rights = CAST(JSON_ARRAY_APPEND(CAST(rights AS JSON), '$', @scheduling_id) AS CHAR)
WHERE cid = 2
AND rights IS NOT NULL
AND TRIM(rights) <> ''
AND JSON_VALID(rights)
AND @scheduling_id IS NOT NULL
AND NOT JSON_CONTAINS(CAST(rights AS JSON), CAST(@scheduling_id AS JSON));
UPDATE yz_system_admin_role
SET rights = CAST(JSON_ARRAY_APPEND(CAST(rights AS JSON), '$', @board_id) AS CHAR)
WHERE cid = 2
@@ -81,13 +92,13 @@ WHERE cid = 2
AND NOT JSON_CONTAINS(CAST(rights AS JSON), CAST(@board_id AS JSON));
-- ---------- 结果校验 ----------
SELECT id, pid, title, path, component_path, platforms, type, sort
SELECT id, pid, title, path, component_path, platforms, type, sort, is_visible
FROM yz_system_menu
WHERE delete_time IS NULL AND path LIKE '/apps/oa/scheduling%'
WHERE delete_time IS NULL
AND (path LIKE '/apps/oa/scheduling%' OR path = '/apps/oa/attendance')
ORDER BY pid, sort;
SELECT id, name, tenant_id,
JSON_CONTAINS(CAST(rights AS JSON), CAST(@scheduling_id AS JSON)) AS has_dir,
JSON_CONTAINS(CAST(rights AS JSON), CAST(@board_id AS JSON)) AS has_board
FROM yz_system_admin_role
WHERE cid = 2 AND rights IS NOT NULL AND TRIM(rights) <> ''
+2
View File
@@ -207,6 +207,8 @@ func Init(_ string) {
new(OaSchedulePlan),
new(OaScheduleEntry),
new(OaScheduleChange),
// 考勤打卡(docs/sql/create_oa_attendance.sql)
new(OaAttendance),
new(OaHoliday),
new(OaNotice),
// 工作日报(docs/sql/create_oa_dailyreport.sql)
+72
View File
@@ -0,0 +1,72 @@
package models
import "time"
// 考勤与排班的匹配状态(match_status)
const (
OaAttendMatchUnknown int8 = 0 // 未判定(历史数据)
OaAttendMatchOK int8 = 1 // 已匹配到当日排班
OaAttendMatchMissing int8 = 2 // 未匹配到排班(仍允许打卡,只做提醒)
)
// 单次打卡结果(clock_in_status / clock_out_status)
// - 签到侧:1 正常 2 迟到 3 无排班打卡 4 不在班次时段
// - 签退侧:1 正常 2 早退 3 无排班打卡 4 不在班次时段
const (
OaAttendClockNone int8 = 0 // 未打卡
OaAttendClockNormal int8 = 1 // 正常
OaAttendClockLate int8 = 2 // 迟到(签退侧表示早退)
OaAttendClockNoShift int8 = 3 // 未匹配排班时的打卡
OaAttendClockOffTime int8 = 4 // 不在班次时段内
)
// 考勤数据来源(source)
const (
OaAttendSourceApp int8 = 1 // 移动端打卡
OaAttendSourceWeb int8 = 2 // 后台补录
)
// OaAttendance OA 考勤打卡 yz_backend_oa_attendance
//
// 一人一天一条:签到与签退写同一行,便于按「人 × 日期」做统计与展示。
// 打卡时会实时联动排班明细(yz_backend_oa_schedule_entry):
// - 匹配上:把班次名称与上下班时间快照到本表(排班后续被改也不影响历史判定);
// - 匹配不上:match_status=2,仍正常落库,clock_*_note 记录提醒文案。
//
// 唯一键为 (tid, uid, attend_date):uid 是登录身份,员工档案缺失时也能打卡,
// employee_id=0 表示未绑定员工(后台只能按账号/姓名检索到)。
type OaAttendance struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid int `orm:"column(tid)" json:"tid"`
Uid uint64 `orm:"column(uid);default(0)" json:"uid"`
EmployeeID uint `orm:"column(employee_id);default(0)" json:"employee_id"`
EmployeeName string `orm:"column(employee_name);size(50);default()" json:"employee_name"`
EmployeeAccount string `orm:"column(employee_account);size(50);default()" json:"employee_account"`
OrgID uint64 `orm:"column(org_id);default(0)" json:"org_id"`
OrgName string `orm:"column(org_name);size(128);default()" json:"org_name"`
Position string `orm:"column(position);size(50);default()" json:"position"`
AttendDate string `orm:"column(attend_date);type(date)" json:"attend_date"`
EntryID uint64 `orm:"column(entry_id);default(0)" json:"entry_id"`
ShiftID uint64 `orm:"column(shift_id);default(0)" json:"shift_id"`
ShiftName string `orm:"column(shift_name);size(50);default()" json:"shift_name"`
ShortName string `orm:"column(short_name);size(20);default()" json:"short_name"`
Color string `orm:"column(color);size(20);default(#409EFF)" json:"color"`
WorkStart string `orm:"column(work_start);size(8);default()" json:"work_start"`
WorkEnd string `orm:"column(work_end);size(8);default()" json:"work_end"`
MatchStatus int8 `orm:"column(match_status);default(0)" json:"match_status"`
ClockInTime *time.Time `orm:"column(clock_in_time);type(datetime);null" json:"clock_in_time"`
ClockInStatus int8 `orm:"column(clock_in_status);default(0)" json:"clock_in_status"`
ClockInNote string `orm:"column(clock_in_note);size(255);default()" json:"clock_in_note"`
ClockOutTime *time.Time `orm:"column(clock_out_time);type(datetime);null" json:"clock_out_time"`
ClockOutStatus int8 `orm:"column(clock_out_status);default(0)" json:"clock_out_status"`
ClockOutNote string `orm:"column(clock_out_note);size(255);default()" json:"clock_out_note"`
Location string `orm:"column(location);size(255);default()" json:"location"`
Remark string `orm:"column(remark);size(255);default()" json:"remark"`
Source int8 `orm:"column(source);default(1)" json:"source"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);auto_now;type(datetime);null" json:"update_time"`
}
func (m *OaAttendance) TableName() string {
return "yz_backend_oa_attendance"
}
+8
View File
@@ -66,6 +66,14 @@ var PlatformSiteSettingDefs = []PlatformSiteSettingDef{
{Tag: "privacy", Title: "隐私政策", Remark: "隐私政策链接,如 /page/privacy | group:政策条款"},
{Tag: "terms", Title: "使用条款", Remark: "使用条款链接,如 /page/terms | group:政策条款"},
// 平台信息(系统设置 → 平台设置 → 平台信息)
{Tag: "logo_square", Title: "平台 Logo(正方形)", Remark: "正方形图标,用于后台顶栏等小尺寸场景 | group:平台信息 | type:image"},
{Tag: "logo_rect", Title: "平台 Logo(长方形)", Remark: "横向品牌图,用于页头等较宽场景 | group:平台信息 | type:image"},
{Tag: "logo_square_dark", Title: "平台 Logo(正方形·深色主题)", Remark: "深色主题下替换方形 Logo,留空沿用浅色主题版本 | group:平台信息 | type:image"},
{Tag: "logo_rect_dark", Title: "平台 Logo(长方形·深色主题)", Remark: "深色主题下替换横向 Logo,留空回退官网浅色 Logo | group:平台信息 | type:image"},
{Tag: "ico", Title: "站点图标(favicon)", Remark: "浏览器标签页图标,建议 32×32 / 64×64 的 .ico 或 PNG | group:平台信息 | type:image"},
{Tag: "platform_intro", Title: "平台简介", Remark: "平台的一句话简介,用于登录页/关于页等品牌说明 | group:平台信息 | type:textarea"},
// 登录页品牌(统一认证中心 /auth/login 左侧展示)
{Tag: "login_title", Title: "登录页主标题", Remark: "登录页左侧主标题,留空则使用默认文案 | group:登录页设置"},
{Tag: "login_subtitle", Title: "登录页副标题", Remark: "登录页左侧副标题,留空则使用默认文案 | group:登录页设置"},
+6
View File
@@ -110,6 +110,12 @@ func Register() {
// OA 工作台仪表盘(移动端首屏顶部聚合:待办/报销统计 + 通知公告 + 今日日程)
beego.Router("/api/oa/dashboard", &controllers.AppOaController{}, "get:Dashboard")
// OA 考勤打卡(移动端签到/签退,联动工作排班;后台 /backend/oa/attendance/* 同源)
beego.Router("/api/oa/attendance/today", &controllers.AppOaController{}, "get:AttendanceToday")
beego.Router("/api/oa/attendance/clock", &controllers.AppOaController{}, "post:AttendanceClock")
beego.Router("/api/oa/attendance/records", &controllers.AppOaController{}, "get:AttendanceRecords")
beego.Router("/api/oa/attendance/stats", &controllers.AppOaController{}, "get:AttendanceStats")
// OA 工作日程(日历待办,与 /backend/oa/schedule/* 同一套数据;小程序「工作日程」页面使用)
beego.Router("/api/oa/schedule/list", &controllers.AppOaController{}, "get:ScheduleList")
beego.Router("/api/oa/schedule/detail/:id", &controllers.AppOaController{}, "get:ScheduleDetail")
+9
View File
@@ -118,6 +118,9 @@ func RegisterAuthRoutes() {
beego.Router("/backend/loginLogs/:id", &controllers.BackendLoginLogController{}, "get:Detail;delete:Delete")
beego.Router("/backend/loginLogs/batchDelete", &controllers.BackendLoginLogController{}, "post:BatchDelete")
// 后台顶栏品牌(Logo 取自平台「官网管理 - 站点设置」)
beego.Router("/backend/brand", &controllers.BackendSiteSettingsController{}, "get:GetBrand")
// 租户站点设置
beego.Router("/backend/normalInfos", &controllers.BackendSiteSettingsController{}, "get:GetNormalInfos")
beego.Router("/backend/saveNormalInfos", &controllers.BackendSiteSettingsController{}, "post:SaveNormalInfos")
@@ -371,6 +374,12 @@ func RegisterAuthRoutes() {
beego.Router("/backend/oa/scheduling/entries/clear", &controllers.BackendOaSchedulingController{}, "post:ClearEntries")
beego.Router("/backend/oa/scheduling/changes", &controllers.BackendOaSchedulingController{}, "get:ChangeList")
// OA考勤打卡(移动端签到/签退,后台查看考勤记录与缺卡名单)
beego.Router("/backend/oa/attendance/records", &controllers.BackendOaAttendanceController{}, "get:RecordList")
beego.Router("/backend/oa/attendance/summary", &controllers.BackendOaAttendanceController{}, "get:Summary")
beego.Router("/backend/oa/attendance/missing", &controllers.BackendOaAttendanceController{}, "get:MissingList")
beego.Router("/backend/oa/attendance/remark/:id", &controllers.BackendOaAttendanceController{}, "post:RecordRemark")
// OA日程管理(日历待办)
beego.Router("/backend/oa/schedule/list", &controllers.BackendOaScheduleController{}, "get:List")
beego.Router("/backend/oa/schedule/stats", &controllers.BackendOaScheduleController{}, "get:Stats")
+286
View File
@@ -0,0 +1,286 @@
// 老数据补齐:为「人员管理里有、用户管理里没有」的老员工补建登录账号。
//
// 背景:早期人员是直接在 OA 人员管理里手工录入的(离线导入),没有对应的登录账号,
// 于是「基础设置 → 用户管理」人少、「OA → 人员管理」人多,两边对不上。
//
// 本脚本走的是和「用户管理 → 添加用户」完全相同的服务链路:
// authsvc.SyncTenantUserAndGetIdentity(建/取统一身份,返回 identity_id)
// → services.BindTenantUser(写 yz_system_tenant_user,uid = identity_id)
// 员工档案已存在,按 (tid, account) 自动关联,不会重复建档、也不会丢部门/职位/排班。
//
// 用法(在 go/ 目录下运行,读取 conf/app.conf):
//
// go run scripts/syncemployeeusers/main.go -tid 2 预演:只列出会建哪些账号
// go run scripts/syncemployeeusers/main.go -tid 2 -apply 真正创建账号并打印初始密码
// go run scripts/syncemployeeusers/main.go -tid 2 -apply -password "Abc@123456"
//
// 安全提示:
// - 未指定 -password 时每个账号生成随机初始密码,执行完请立即转告本人并让其修改;
// - 若员工手机号/邮箱已属于另一个身份,脚本会跳过并提示,避免把两个人错误合并。
package main
import (
"crypto/rand"
"flag"
"fmt"
"math/big"
"os"
"strconv"
"strings"
"server/models"
"server/pkg/passwordutil"
"server/services"
authsvc "server/services/auth"
beego "github.com/beego/beego/v2/server/web"
"github.com/beego/beego/v2/client/orm"
)
func main() {
tid := flag.Int64("tid", 0, "只处理指定企业;0 表示全部企业")
fixedPwd := flag.String("password", "", "统一初始密码;留空则每个账号随机生成")
apply := flag.Bool("apply", false, "真正写入(默认只预演)")
flag.Parse()
if err := beego.LoadAppConfig("ini", "conf/app.conf"); err != nil {
fmt.Println("读取 conf/app.conf 失败:", err)
os.Exit(1)
}
models.Init("")
type pending struct {
empID int64
tid int64
account string
name string
phone string
email string
gender int64
birth string
dept string
status int64
}
// 员工侧:所有未删除员工
empQuery := models.Orm.QueryTable(new(models.BackendEmployee)).Filter("delete_time__isnull", true)
if *tid > 0 {
empQuery = empQuery.Filter("tid", *tid)
}
var employees []models.BackendEmployee
if _, err := empQuery.OrderBy("tid", "id").All(&employees); err != nil && err != orm.ErrNoRows {
fmt.Println("读取员工失败:", err)
os.Exit(1)
}
// 用户侧:(tid, account) 已有的账号
userQuery := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("delete_time__isnull", true)
if *tid > 0 {
userQuery = userQuery.Filter("tid", *tid)
}
var users []models.SystemTenantUser
if _, err := userQuery.All(&users); err != nil && err != orm.ErrNoRows {
fmt.Println("读取用户失败:", err)
os.Exit(1)
}
exists := make(map[string]struct{}, len(users))
for _, u := range users {
if u.Account == nil {
continue
}
exists[fmt.Sprintf("%d|%s", u.Tid, strings.TrimSpace(*u.Account))] = struct{}{}
}
var todo []pending
for _, e := range employees {
if e.Tid == nil || *e.Tid <= 0 {
continue
}
account := strings.TrimSpace(e.Account)
if account == "" {
continue
}
if _, ok := exists[fmt.Sprintf("%d|%s", *e.Tid, account)]; ok {
continue
}
item := pending{
empID: int64(e.ID),
tid: int64(*e.Tid),
account: account,
name: strings.TrimSpace(e.Name),
gender: int64(e.Gender),
status: int64(e.AccountStatus),
}
if item.name == "" {
item.name = account
}
if e.Phone != nil {
item.phone = strings.TrimSpace(*e.Phone)
}
if e.Email != nil {
item.email = strings.TrimSpace(*e.Email)
}
if e.Birthday != nil {
item.birth = e.Birthday.Format("2006-01-02")
}
if e.Department != nil {
item.dept = strings.TrimSpace(*e.Department)
}
todo = append(todo, item)
}
if len(todo) == 0 {
fmt.Println("没有需要补建账号的老员工,两边已经一致。")
return
}
fmt.Printf("待补建账号:%d 个\n", len(todo))
for _, item := range todo {
fmt.Printf(" tid=%d 员工id=%d account=%-16s name=%s dept=%s phone=%s\n",
item.tid, item.empID, item.account, item.name, item.dept, item.phone)
}
if !*apply {
fmt.Println("\n预演结束(未写入)。确认无误后加 -apply 执行。")
return
}
fmt.Println()
created := 0
for _, item := range todo {
if conflict := identityConflict(item.account, item.phone, item.email); conflict != "" {
fmt.Printf(" 跳过 %s:%s\n", item.account, conflict)
continue
}
plain := *fixedPwd
if plain == "" {
plain = randomPassword()
}
hashed, err := passwordutil.Hash(plain)
if err != nil {
fmt.Printf(" 跳过 %s:密码加密失败 %v\n", item.account, err)
continue
}
uid, err := authsvc.SyncTenantUserAndGetIdentity(authsvc.SyncTenantUserInput{
Tid: uint64(item.tid),
Account: item.account,
Name: item.name,
Phone: item.phone,
Email: item.email,
PasswordHash: hashed,
Status: 1,
OrgID: parseUint(item.dept),
})
if err != nil {
fmt.Printf(" 跳过 %s:创建统一身份失败 %v\n", item.account, err)
continue
}
account := item.account
name := item.name
var phonePtr, emailPtr, birthPtr, pwdPtr *string
if item.phone != "" {
phonePtr = &item.phone
}
if item.email != "" {
emailPtr = &item.email
}
if item.birth != "" {
birthPtr = &item.birth
}
pwdPtr = &hashed
var sexPtr *uint8
if item.gender > 0 {
sex := uint8(item.gender)
sexPtr = &sex
}
status := int8(1)
if item.status == 0 {
status = 0
}
id, err := services.BindTenantUser(
uint64(item.tid), uid, &account, &name, phonePtr, emailPtr,
sexPtr, birthPtr, pwdPtr, 0, status, nil,
)
if err != nil {
fmt.Printf(" 跳过 %s:写入租户用户失败 %v\n", item.account, err)
continue
}
// 部门:与员工档案保持一致(用户管理表单里的所属部门)
if deptID := parseUint(item.dept); deptID > 0 {
_, _ = models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("id", id).
Update(orm.Params{"org_id": deptID})
}
created++
fmt.Printf(" 已创建:tid=%d account=%s name=%s 初始密码=%s(员工id=%d 已自动关联)\n",
item.tid, item.account, item.name, plain, item.empID)
}
fmt.Printf("\n完成:新建 %d 个账号,跳过 %d 个\n", created, len(todo)-created)
}
// identityConflict 手机号/邮箱已被其他账号占用时返回原因,避免错误合并两个自然人
func identityConflict(account, phone, email string) string {
if phone != "" {
var rows []models.AuthIdentity
_, _ = models.Orm.QueryTable(new(models.AuthIdentity)).
Filter("mobile", phone).All(&rows)
for _, r := range rows {
if r.Account == nil || strings.TrimSpace(*r.Account) != account {
return "手机号 " + phone + " 已属于其他统一身份(identity " + strconv.FormatUint(r.ID, 10) + ")"
}
}
}
if email != "" {
var rows []models.AuthIdentity
_, _ = models.Orm.QueryTable(new(models.AuthIdentity)).
Filter("email", email).All(&rows)
for _, r := range rows {
if r.Account == nil || strings.TrimSpace(*r.Account) != account {
return "邮箱 " + email + " 已属于其他统一身份(identity " + strconv.FormatUint(r.ID, 10) + ")"
}
}
}
return ""
}
func parseUint(v string) uint64 {
n, err := strconv.ParseUint(strings.TrimSpace(v), 10, 64)
if err != nil {
return 0
}
return n
}
// randomPassword 生成 12 位含大小写与数字的随机初始密码
func randomPassword() string {
const lower = "abcdefghijkmnpqrstuvwxyz"
const upper = "ABCDEFGHJKLMNPQRSTUVWXYZ"
const digits = "23456789"
pool := lower + upper + digits
buf := make([]byte, 12)
for i := range buf {
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(pool))))
if err != nil {
buf[i] = pool[i%len(pool)]
continue
}
buf[i] = pool[n.Int64()]
}
// 保证至少各含一位,避免被前端复杂度校验拦住
buf[0] = upper[randIdx(len(upper))]
buf[1] = lower[randIdx(len(lower))]
buf[2] = digits[randIdx(len(digits))]
return string(buf)
}
func randIdx(n int) int {
v, err := rand.Int(rand.Reader, big.NewInt(int64(n)))
if err != nil {
return 0
}
return int(v.Int64())
}
+3 -5
View File
@@ -44,15 +44,13 @@ func EnsureEmployeeForTenantUser(tu *models.SystemTenantUser) (*models.BackendEm
return nil, false, nil
}
// 员工表按 (tid, account) 唯一:同一账号在不同企业各有一份员工档案,
// 因此这里必须带上 tid,否则会把其他企业的同账号员工误判为本企业已有。
var emp models.BackendEmployee
// 员工表的 uk_account 是全局唯一索引(不是 tid+account 复合索引)。
// 因此先按账号查全表,避免已有的历史/其他租户同账号记录被误判为缺失后再 INSERT。
err := models.Orm.QueryTable(new(models.BackendEmployee)).
Filter("tid", tid).
Filter("account", account).
One(&emp)
if err == nil && emp.Tid != nil && *emp.Tid > 0 && *emp.Tid != tid {
return nil, false, fmt.Errorf("员工账号 %s 已被其他租户使用,不能重复创建", account)
}
name := account
if tu.Name != nil && strings.TrimSpace(*tu.Name) != "" {