Files
yunzerwebsiteallinone/go/controllers/backend_oa_attendance.go
T

920 lines
29 KiB
Go
Raw Blame History

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