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