增加ai办公,考勤管理
This commit is contained in:
1 parent
eb9b7beff5
commit
31ae07175d
46 files changed
+9250
-296
No files matched your search
@@ -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))
|
||||
}
|
||||
Reference in new issue
Block a user