增加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

@@ -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()
}