720 lines
23 KiB
Go
720 lines
23 KiB
Go
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()
|
|
}
|