package dailyreport import ( "math" "sort" "strings" "server/models" "github.com/beego/beego/v2/client/orm" ) // TeamQuery 团队日报汇总查询。 type TeamQuery struct { Date string OrgID uint64 Keyword string Page int PageSize int } // TeamStats 团队提交统计。 type TeamStats struct { Total int `json:"total"` Submitted int `json:"submitted"` Missing int `json:"missing"` Rate float64 `json:"rate"` // 提交率百分比,保留一位小数 } // TeamRow 团队汇总中的一名成员(仅包含当日已提交日报的成员)。 type TeamRow struct { UserID uint64 `json:"user_id"` UserName string `json:"user_name"` Account string `json:"account"` OrgID uint64 `json:"org_id"` OrgName string `json:"org_name"` Submitted bool `json:"submitted"` ReportID uint64 `json:"report_id"` Status int8 `json:"status"` // 0草稿 1已提交(团队列表只返回 1) SubmitTime string `json:"submit_time"` HasIssue bool `json:"has_issue"` Summary string `json:"summary"` // 日报内容摘要(优先今日完成) } // TeamResult 团队汇总结果。 type TeamResult struct { Date string `json:"date"` List []TeamRow `json:"list"` Total int64 `json:"total"` Stats TeamStats `json:"stats"` } // OrgOption 团队筛选用的部门选项(前端按 parent_id 组装树)。 type OrgOption struct { ID uint64 `json:"id"` ParentID uint64 `json:"parent_id"` Name string `json:"name"` MemberCount int `json:"member_count"` } // TeamStatus 团队日报日志池:只返回当日已提交的日报,未提交(含仅存草稿)的成员不展示。 // 列表按提交时间倒序排列(按“谁发布了内容”展示,不按人分组),右侧根据选中项展示日报内容。 // 管理员不选部门时统计全租户(含未分配部门的成员)。 func TeamStatus(u *UserContext, q TeamQuery) (*TeamResult, error) { if !u.CanViewTeam { return nil, errForbidden("无权查看团队日报") } date, err := ParseDate(q.Date) if err != nil { return nil, err } if date > Today() { return nil, errBad("不能查看未来日期的团队日报") } if q.Page < 1 { q.Page = 1 } if q.PageSize < 1 || q.PageSize > 100 { q.PageSize = 20 } orgs, err := loadOrgs(u.Tid) if err != nil { return nil, err } orgByID := make(map[uint64]*models.BackendOrganization, len(orgs)) for i := range orgs { orgByID[orgs[i].ID] = &orgs[i] } var scopeIDs []uint64 if q.OrgID > 0 { if !u.IsAdmin && !u.InTeamOrg(q.OrgID) { return nil, errForbidden("无权查看该部门") } if _, ok := orgByID[q.OrgID]; !ok { return nil, errBad("部门不存在") } scopeIDs = collectSubtree([]uint64{q.OrgID}, orgs) } else if !u.IsAdmin { scopeIDs = u.TeamOrgIDs } if scopeIDs != nil && len(scopeIDs) == 0 { return nil, errForbidden("你还没有管辖的部门") } // 管辖范围内的成员:用于姓名/部门补全与提交统计,不受关键词与分页影响。 memberCond := orm.NewCondition(). And("tid", u.Tid). And("delete_time__isnull", true). And("status", 1) if scopeIDs != nil { memberCond = memberCond.And("org_id__in", scopeIDs) } var allMembers []models.SystemTenantUser if _, err := models.Orm.QueryTable(new(models.SystemTenantUser)). SetCond(memberCond).OrderBy("org_id", "uid").All(&allMembers); err != nil { return nil, err } memberByUID := make(map[uint64]*models.SystemTenantUser, len(allMembers)) for i := range allMembers { memberByUID[allMembers[i].Uid] = &allMembers[i] } reports, err := loadReportsByDate(u.Tid, date) if err != nil { return nil, err } stats := TeamStats{Total: len(allMembers)} kw := strings.TrimSpace(q.Keyword) rows := make([]TeamRow, 0, len(allMembers)) for i := range allMembers { m := &allMembers[i] r := reports[m.Uid] if r == nil || r.Status != 1 { continue } stats.Submitted++ row := buildTeamRow(r, m.OrgID, userNameOf(m), ptrString(m.Account), "") if o := orgByID[m.OrgID]; o != nil { row.OrgName = o.OrgName } if kw != "" && !matchTeamKeyword(row.UserName, row.Account, kw) { continue } rows = append(rows, row) } // 作者不在成员表(成员已删除 / 组织映射异常)时用日报快照补齐, // 保证已提交的内容不会因人员变动而看不到。 for uid, r := range reports { if r.Status != 1 { continue } if _, ok := memberByUID[uid]; ok { continue } if scopeIDs != nil && !containsID(scopeIDs, r.OrgID) { continue } row := buildTeamRow(r, r.OrgID, r.UserName, "", r.OrgName) if kw != "" && !matchTeamKeyword(row.UserName, row.Account, kw) { continue } rows = append(rows, row) } // 日志池排序:按发布时间(提交时间)倒序,最新提交排在最前。 sort.SliceStable(rows, func(i, j int) bool { if rows[i].SubmitTime != rows[j].SubmitTime { return rows[i].SubmitTime > rows[j].SubmitTime } return rows[i].ReportID > rows[j].ReportID }) stats.Missing = stats.Total - stats.Submitted if stats.Missing < 0 { stats.Missing = 0 } if stats.Total > 0 { stats.Rate = math.Round(float64(stats.Submitted)*1000/float64(stats.Total)) / 10 } // 列表分页:只对已提交的日报分页。 total := len(rows) start := (q.Page - 1) * q.PageSize if start > total { start = total } end := start + q.PageSize if end > total { end = total } return &TeamResult{Date: date, List: rows[start:end], Total: int64(total), Stats: stats}, nil } // buildTeamRow 组装一条团队日报行;摘要取日报正文。 func buildTeamRow(r *models.OaDailyReport, orgID uint64, userName, account, orgName string) TeamRow { return TeamRow{ UserID: r.UserID, UserName: userName, Account: account, OrgID: orgID, OrgName: orgName, Submitted: true, ReportID: r.ID, Status: r.Status, SubmitTime: formatTime(r.SubmitTime), HasIssue: strings.TrimSpace(r.Issues) != "", Summary: reportSummary(r), } } // reportSummary 生成团队列表用的内容摘要(优先今日完成),超长截断。 func reportSummary(r *models.OaDailyReport) string { for _, text := range []string{r.TodayDone, r.TomorrowPlan, r.Issues} { s := strings.TrimSpace(text) if s == "" { continue } runes := []rune(s) if len(runes) > 60 { return string(runes[:60]) + "…" } return s } return "" } // matchTeamKeyword 按姓名/账号模糊匹配团队筛选项。 func matchTeamKeyword(name, account, kw string) bool { kw = strings.ToLower(kw) return strings.Contains(strings.ToLower(name), kw) || strings.Contains(strings.ToLower(account), kw) } // TeamOrgs 返回团队筛选可选部门;管理员为全租户,主管仅自己管辖的部门子树。 func TeamOrgs(u *UserContext) ([]OrgOption, error) { if !u.CanViewTeam { return nil, errForbidden("无权查看团队日报") } orgs, err := loadOrgs(u.Tid) if err != nil { return nil, err } allow := make(map[uint64]bool, len(orgs)) if u.IsAdmin { for _, o := range orgs { allow[o.ID] = true } } else { for _, id := range u.TeamOrgIDs { allow[id] = true } } var users []models.SystemTenantUser if _, err := models.Orm.QueryTable(new(models.SystemTenantUser)). Filter("tid", u.Tid). Filter("delete_time__isnull", true). Filter("status", 1). All(&users, "org_id"); err != nil { return nil, err } counts := make(map[uint64]int, len(orgs)) for _, m := range users { if m.OrgID > 0 { counts[m.OrgID]++ } } out := make([]OrgOption, 0, len(allow)) for i := range orgs { o := &orgs[i] if !allow[o.ID] { continue } out = append(out, OrgOption{ ID: o.ID, ParentID: o.ParentID, Name: o.OrgName, MemberCount: counts[o.ID], }) } return out, nil } // loadReportsByDate 一次取回某日全部已提交/草稿日报,按作者 uid 建索引。 func loadReportsByDate(tid uint64, date string) (map[uint64]*models.OaDailyReport, error) { var rows []models.OaDailyReport if _, err := models.Orm.QueryTable(new(models.OaDailyReport)). Filter("tid", tid). Filter("report_date", date). Filter("is_deleted", 0). All(&rows); err != nil { return nil, err } out := make(map[uint64]*models.OaDailyReport, len(rows)) for i := range rows { out[rows[i].UserID] = &rows[i] } return out, nil } func userNameOf(m *models.SystemTenantUser) string { if n := strings.TrimSpace(ptrString(m.Name)); n != "" { return n } if a := strings.TrimSpace(ptrString(m.Account)); a != "" { return a } return "未命名成员" }