Files
2026-10-22 00:27:26 +08:00

316 lines
8.4 KiB
Go

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 "未命名成员"
}