package dailyreport import ( "encoding/json" "fmt" "strings" "time" "unicode/utf8" "server/models" "github.com/beego/beego/v2/client/orm" ) // Attachment 日报附件,与 PC / 小程序上传接口返回的文件信息对齐。 type Attachment struct { ID uint64 `json:"id"` URL string `json:"url"` Name string `json:"name"` Size int64 `json:"size"` } // ReportItem 日报列表/详情中的主体。 type ReportItem struct { ID uint64 `json:"id"` UserID uint64 `json:"user_id"` UserName string `json:"user_name"` OrgID uint64 `json:"org_id"` OrgName string `json:"org_name"` ReportDate string `json:"report_date"` TodayDone string `json:"today_done"` TomorrowPlan string `json:"tomorrow_plan"` Issues string `json:"issues"` Attachments []Attachment `json:"attachments"` Status int8 `json:"status"` SubmitTime string `json:"submit_time"` CreateTime string `json:"create_time"` UpdateTime string `json:"update_time"` CommentCount int `json:"comment_count"` } // Comment 日报评论(两级回复)。 type Comment struct { ID uint64 `json:"id"` UserID uint64 `json:"user_id"` UserName string `json:"user_name"` Content string `json:"content"` ReplyToID uint64 `json:"reply_to_id"` ReplyToName string `json:"reply_to_name"` CreateTime string `json:"create_time"` IsMine bool `json:"is_mine"` CanDelete bool `json:"can_delete"` } // Detail 日报详情:主体 + 评论 + 修改痕迹 + 当前用户可执行的操作。 type Detail struct { Report ReportItem `json:"report"` Comments []Comment `json:"comments"` Histories []HistoryItem `json:"histories"` IsAuthor bool `json:"is_author"` CanEdit bool `json:"can_edit"` CanDelete bool `json:"can_delete"` CanComment bool `json:"can_comment"` CanWithdraw bool `json:"can_withdraw"` } // HistoryItem 日报修改痕迹:撤销提交 / 撤销后重新提交时的内容快照。 type HistoryItem struct { ID uint64 `json:"id"` Action int8 `json:"action"` // 1撤销提交 2重新提交 UserName string `json:"user_name"` TodayDone string `json:"today_done"` TomorrowPlan string `json:"tomorrow_plan"` Issues string `json:"issues"` Attachments []Attachment `json:"attachments"` SubmitTime string `json:"submit_time"` CreateTime string `json:"create_time"` } // SaveInput 保存日报入参;Submit=true 表示提交,否则保存草稿。 type SaveInput struct { Date string `json:"date"` TodayDone string `json:"today_done"` TomorrowPlan string `json:"tomorrow_plan"` Issues string `json:"issues"` Attachments string `json:"attachments"` Submit bool `json:"submit"` } // MineQuery 我的日报列表查询。 type MineQuery struct { StartDate string EndDate string Page int PageSize int } // MineSummary 我的日报页头统计。 type MineSummary struct { MonthTotal int `json:"month_total"` MonthSubmitted int `json:"month_submitted"` TodayStatus int8 `json:"today_status"` // -1=今天未填写 TodayReportID uint64 `json:"today_report_id"` WindowStart string `json:"window_start"` WindowEnd string `json:"window_end"` } // MineResult 我的日报列表结果。 type MineResult struct { List []ReportItem `json:"list"` Total int64 `json:"total"` Summary MineSummary `json:"summary"` } // Save 新建或更新一条日报(同一人同一天唯一)。 // 已提交的日报处于锁定状态,必须先撤销提交退回草稿才能修改。 func Save(u *UserContext, in SaveInput) (*ReportItem, error) { date, err := ParseDate(in.Date) if err != nil { return nil, err } if !InWindow(date) { return nil, errBad(fmt.Sprintf("只能补写最近%d天(%s 至 %s)的日报", BackfillDays, WindowStart(), Today())) } in.TodayDone = strings.TrimSpace(in.TodayDone) in.TomorrowPlan = strings.TrimSpace(in.TomorrowPlan) in.Issues = strings.TrimSpace(in.Issues) for name, text := range map[string]string{ "今日完成": in.TodayDone, "明日计划": in.TomorrowPlan, "问题与建议": in.Issues, } { if utf8.RuneCountInString(text) > 5000 { return nil, errBad(name + "不能超过5000字") } } if in.TodayDone == "" && in.TomorrowPlan == "" && in.Issues == "" { return nil, errBad("请至少填写今日完成、明日计划、问题与建议中的一项") } attachmentJSON, _, err := normalizeAttachments(in.Attachments) if err != nil { return nil, err } var existing models.OaDailyReport findErr := models.Orm.QueryTable(new(models.OaDailyReport)). Filter("tid", u.Tid). Filter("user_id", u.UID). Filter("report_date", date). Filter("is_deleted", 0). One(&existing) status := int8(0) if in.Submit { status = 1 } now := time.Now() if findErr == orm.ErrNoRows { report := models.OaDailyReport{ Tid: u.Tid, UserID: u.UID, UserName: u.Name, OrgID: u.OrgID, OrgName: u.OrgName, ReportDate: date, TodayDone: in.TodayDone, TomorrowPlan: in.TomorrowPlan, Issues: in.Issues, Attachments: attachmentJSON, Status: status, } if status == 1 { report.SubmitTime = &now } if _, err := models.Orm.Insert(&report); err != nil { return nil, errBad("保存失败,该日期可能已存在日报,请刷新后重试") } return buildItem(&report, 0), nil } if findErr != nil { return nil, findErr } // 已提交的日报锁定:必须先撤销提交退回草稿,才能修改内容。 if existing.Status == 1 { return nil, errBad("日报已提交并锁定,如需修改请先撤销提交") } // 撤销过提交的日报再次提交时,留下重新提交痕迹。 resubmit := false if in.Submit { if existing.SubmitTime != nil { resubmit = true } else { resubmit = hasWithdrawHistory(u.Tid, existing.ID) } existing.SubmitTime = &now } existing.TodayDone = in.TodayDone existing.TomorrowPlan = in.TomorrowPlan existing.Issues = in.Issues existing.Attachments = attachmentJSON existing.Status = status existing.UserName = u.Name existing.OrgID = u.OrgID existing.OrgName = u.OrgName tx, err := models.Orm.Begin() if err != nil { return nil, err } if _, err := tx.Update(&existing, "today_done", "tomorrow_plan", "issues", "attachments", "status", "submit_time", "user_name", "org_id", "org_name"); err != nil { _ = tx.Rollback() return nil, err } if resubmit { history := models.OaDailyReportHistory{ Tid: u.Tid, ReportID: existing.ID, UserID: u.UID, UserName: u.Name, ReportDate: date, Action: 2, TodayDone: existing.TodayDone, TomorrowPlan: existing.TomorrowPlan, Issues: existing.Issues, Attachments: existing.Attachments, SubmitTime: existing.SubmitTime, } if _, err := tx.Insert(&history); err != nil { _ = tx.Rollback() return nil, err } } if err := tx.Commit(); err != nil { return nil, err } count := commentCount(u.Tid, existing.ID) return buildItem(&existing, count), nil } // ListMine 我的日报分页列表 + 页头统计。 func ListMine(u *UserContext, q MineQuery) (*MineResult, error) { if q.Page < 1 { q.Page = 1 } if q.PageSize < 1 || q.PageSize > 100 { q.PageSize = 20 } base := models.Orm.QueryTable(new(models.OaDailyReport)). Filter("tid", u.Tid). Filter("user_id", u.UID). Filter("is_deleted", 0) start, err := optionalDate(q.StartDate) if err != nil { return nil, err } end, err := optionalDate(q.EndDate) if err != nil { return nil, err } if start != "" { base = base.Filter("report_date__gte", start) } if end != "" { base = base.Filter("report_date__lte", end) } total, _ := base.Count() var rows []models.OaDailyReport if _, err := base.OrderBy("-report_date", "-id"). Limit(q.PageSize).Offset((q.Page - 1) * q.PageSize). All(&rows); err != nil { return nil, err } ids := make([]uint64, 0, len(rows)) for i := range rows { ids = append(ids, rows[i].ID) } counts := commentCounts(u.Tid, ids) list := make([]ReportItem, 0, len(rows)) for i := range rows { list = append(list, *buildItem(&rows[i], counts[rows[i].ID])) } summary, err := mineSummary(u) if err != nil { return nil, err } return &MineResult{List: list, Total: total, Summary: *summary}, nil } // CalendarDay 我的日报日历热力图单日状态。 type CalendarDay struct { Date string `json:"date"` Status int8 `json:"status"` // 0草稿 1已提交 ID uint64 `json:"id"` } // CalendarResult 近期热力图数据(默认近三个月)。 type CalendarResult struct { StartDate string `json:"start_date"` EndDate string `json:"end_date"` Total int `json:"total"` Submitted int `json:"submitted"` Days []CalendarDay `json:"days"` } // Calendar 返回最近 months 个月(含本月)本人日报的逐日状态,供热力图按月/周渲染。 // months 越界时回落到 3 个月。 func Calendar(u *UserContext, months int) (*CalendarResult, error) { if months < 1 || months > 12 { months = 3 } now := time.Now() monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) start := monthStart.AddDate(0, -(months - 1), 0) end := monthStart.AddDate(0, 1, -1) startDate := start.Format("2006-01-02") endDate := end.Format("2006-01-02") var rows []models.OaDailyReport if _, err := models.Orm.QueryTable(new(models.OaDailyReport)). Filter("tid", u.Tid). Filter("user_id", u.UID). Filter("is_deleted", 0). Filter("report_date__gte", startDate). Filter("report_date__lte", endDate). OrderBy("report_date"). All(&rows, "id", "report_date", "status"); err != nil { return nil, err } res := &CalendarResult{ StartDate: startDate, EndDate: endDate, Days: make([]CalendarDay, 0, len(rows)), } for i := range rows { res.Days = append(res.Days, CalendarDay{ // DATE 列在 parseTime=true 下会带上时间与时区,必须归一化为 YYYY-MM-DD, // 否则前端按日期匹配热力图格子时永远匹配不到。 Date: dateOnly(rows[i].ReportDate), Status: rows[i].Status, ID: rows[i].ID, }) if rows[i].Status == 1 { res.Submitted++ } } res.Total = len(rows) return res, nil } // mineSummary 本月提交统计与今天的状态。 func mineSummary(u *UserContext) (*MineSummary, error) { now := time.Now() monthStart := now.Format("2006-01") + "-01" monthEnd := now.AddDate(0, 1, -1).Format("2006-01-02") out := &MineSummary{ TodayStatus: -1, WindowStart: WindowStart(), WindowEnd: Today(), } base := models.Orm.QueryTable(new(models.OaDailyReport)). Filter("tid", u.Tid). Filter("user_id", u.UID). Filter("is_deleted", 0) out.MonthTotal = int(countWhere(base, "report_date__gte", monthStart, "report_date__lte", monthEnd)) out.MonthSubmitted = int(countWhere(base.Filter("status", 1), "report_date__gte", monthStart, "report_date__lte", monthEnd)) var today models.OaDailyReport if err := models.Orm.QueryTable(new(models.OaDailyReport)). Filter("tid", u.Tid).Filter("user_id", u.UID). Filter("report_date", Today()). Filter("is_deleted", 0).One(&today); err == nil { out.TodayStatus = today.Status out.TodayReportID = today.ID } return out, nil } // GetDetail 读取日报详情;无权限时返回 403。 func GetDetail(u *UserContext, id uint64) (*Detail, error) { report, err := loadReport(u.Tid, id) if err != nil { return nil, err } if !canViewReport(u, report) { return nil, errForbidden("无权查看该日报") } comments := make([]Comment, 0) if report.Status == 1 { comments, err = loadComments(u, report.ID) if err != nil { return nil, err } } author := report.UserID == u.UID canEdit := author && report.Status == 0 && InWindow(report.ReportDate) canWithdraw := author && report.Status == 1 && InWindow(report.ReportDate) // 删除仅限草稿:已提交日报需先撤销提交才能删除。 canDelete := report.Status == 0 && (u.IsAdmin || (author && InWindow(report.ReportDate))) // 修改痕迹只对作者本人与租户管理员可见。 histories := make([]HistoryItem, 0) if author || u.IsAdmin { histories, err = loadHistories(u.Tid, report.ID) if err != nil { // 痕迹表缺失或查询异常时降级为空,避免详情整体 500(部署时需执行 create_oa_dailyreport_history.sql)。 histories = make([]HistoryItem, 0) } } return &Detail{ Report: *buildItem(report, len(comments)), Comments: comments, Histories: histories, IsAuthor: author, CanEdit: canEdit, CanDelete: canDelete, CanComment: report.Status == 1 && (author || u.IsAdmin || u.InTeamOrg(report.OrgID)), CanWithdraw: canWithdraw, }, nil } // Withdraw 撤销提交:把已提交日报退回草稿,并把本次提交的内容快照写入修改痕迹。 // 仅作者本人、且日报在补写窗口内时可操作;撤销后需修改并重新提交。 func Withdraw(u *UserContext, id uint64) (*ReportItem, error) { report, err := loadReport(u.Tid, id) if err != nil { return nil, err } if report.UserID != u.UID { return nil, errForbidden("只能撤销自己提交的日报") } if report.Status != 1 { return nil, errBad("日报尚未提交,无需撤销") } if !InWindow(report.ReportDate) { return nil, errBad(fmt.Sprintf("只能撤销最近%d天(%s 至 %s)的日报", BackfillDays, WindowStart(), Today())) } tx, err := models.Orm.Begin() if err != nil { return nil, err } history := models.OaDailyReportHistory{ Tid: u.Tid, ReportID: report.ID, UserID: u.UID, UserName: u.Name, ReportDate: report.ReportDate, Action: 1, TodayDone: report.TodayDone, TomorrowPlan: report.TomorrowPlan, Issues: report.Issues, Attachments: report.Attachments, SubmitTime: report.SubmitTime, } if _, err := tx.Insert(&history); err != nil { _ = tx.Rollback() return nil, err } if _, err := tx.QueryTable(new(models.OaDailyReport)). Filter("tid", u.Tid).Filter("id", report.ID). Update(map[string]interface{}{"status": 0, "submit_time": nil}); err != nil { _ = tx.Rollback() return nil, err } if err := tx.Commit(); err != nil { return nil, err } report.Status = 0 count := commentCount(u.Tid, report.ID) return buildItem(report, count), nil } // Delete 软删除日报及其评论:作者限补写窗口内,管理员不受限。 // // 日报的 is_deleted 写入记录 ID(而非 1),既标记删除,又让唯一键 // (tid, user_id, report_date, is_deleted) 在「删除后同一天重新填写」时不冲突。 func Delete(u *UserContext, id uint64) error { report, err := loadReport(u.Tid, id) if err != nil { return err } // 已提交日报锁定:不允许直接删除,需先撤销提交退回草稿。 if report.Status == 1 { return errBad("日报已提交并锁定,请先撤销提交后再删除") } author := report.UserID == u.UID if !author && !u.IsAdmin { return errForbidden("无权删除该日报") } if author && !u.IsAdmin && !InWindow(report.ReportDate) { return errBad(fmt.Sprintf("只能删除最近%d天(%s 至 %s)的日报", BackfillDays, WindowStart(), Today())) } now := time.Now() tx, err := models.Orm.Begin() if err != nil { return err } if _, err := tx.QueryTable(new(models.OaDailyReportComment)). Filter("tid", u.Tid).Filter("report_id", report.ID).Filter("is_deleted", 0). Update(map[string]interface{}{"is_deleted": 1, "delete_time": now}); err != nil { _ = tx.Rollback() return err } if _, err := tx.QueryTable(new(models.OaDailyReport)). Filter("tid", u.Tid).Filter("id", report.ID). Update(map[string]interface{}{"is_deleted": report.ID, "delete_time": now}); err != nil { _ = tx.Rollback() return err } return tx.Commit() } // AddComment 对已提交日报发表评论或回复;replyToID=0 表示直接评论。 func AddComment(u *UserContext, reportID uint64, content string, replyToID uint64) (*Comment, error) { content = strings.TrimSpace(content) if content == "" { return nil, errBad("请输入评论内容") } if utf8.RuneCountInString(content) > 1000 { return nil, errBad("评论不能超过1000字") } report, err := loadReport(u.Tid, reportID) if err != nil { return nil, err } if !canViewReport(u, report) { return nil, errForbidden("无权评论该日报") } if report.Status != 1 { return nil, errBad("日报提交后才能评论") } replyName := "" if replyToID > 0 { var parent models.OaDailyReportComment if err := models.Orm.QueryTable(new(models.OaDailyReportComment)). Filter("tid", u.Tid).Filter("id", replyToID).Filter("report_id", reportID). Filter("is_deleted", 0). One(&parent); err != nil { return nil, errBad("回复的评论不存在") } replyName = parent.UserName } row := models.OaDailyReportComment{ Tid: u.Tid, ReportID: reportID, UserID: u.UID, UserName: u.Name, Content: content, ReplyToID: replyToID, ReplyToName: replyName, } if _, err := models.Orm.Insert(&row); err != nil { return nil, err } c := buildComment(u, &row) return &c, nil } // DeleteComment 软删除评论:评论作者本人或管理员。 func DeleteComment(u *UserContext, commentID uint64) error { var row models.OaDailyReportComment if err := models.Orm.QueryTable(new(models.OaDailyReportComment)). Filter("tid", u.Tid).Filter("id", commentID).Filter("is_deleted", 0).One(&row); err != nil { return errNotFound("评论不存在或已删除") } if row.UserID != u.UID && !u.IsAdmin { return errForbidden("只能删除自己的评论") } _, err := models.Orm.QueryTable(new(models.OaDailyReportComment)). Filter("tid", u.Tid).Filter("id", commentID).Filter("is_deleted", 0). Update(map[string]interface{}{"is_deleted": 1, "delete_time": time.Now()}) return err } // ========================== 内部工具 ========================== func loadReport(tid, id uint64) (*models.OaDailyReport, error) { var report models.OaDailyReport if err := models.Orm.QueryTable(new(models.OaDailyReport)). Filter("tid", tid).Filter("id", id).Filter("is_deleted", 0).One(&report); err != nil { if err == orm.ErrNoRows { return nil, errNotFound("日报不存在或已删除") } return nil, err } // 统一成 YYYY-MM-DD,保证 InWindow 与前端日期比较正确。 report.ReportDate = dateOnly(report.ReportDate) return &report, nil } // canViewReport 本人 / 管理员 / 作者所在部门的主管;草稿仅本人与管理员可见。 func canViewReport(u *UserContext, r *models.OaDailyReport) bool { if r.UserID == u.UID || u.IsAdmin { return true } if r.Status != 1 { return false } return u.InTeamOrg(r.OrgID) } func loadComments(u *UserContext, reportID uint64) ([]Comment, error) { var rows []models.OaDailyReportComment if _, err := models.Orm.QueryTable(new(models.OaDailyReportComment)). Filter("tid", u.Tid).Filter("report_id", reportID). Filter("is_deleted", 0). OrderBy("id").All(&rows); err != nil { return nil, err } out := make([]Comment, 0, len(rows)) for i := range rows { out = append(out, buildComment(u, &rows[i])) } if out == nil { out = []Comment{} } return out, nil } func buildComment(u *UserContext, r *models.OaDailyReportComment) Comment { return Comment{ ID: r.ID, UserID: r.UserID, UserName: r.UserName, Content: r.Content, ReplyToID: r.ReplyToID, ReplyToName: r.ReplyToName, CreateTime: r.CreateTime.Format("2006-01-02 15:04"), IsMine: r.UserID == u.UID, CanDelete: r.UserID == u.UID || u.IsAdmin, } } // hasWithdrawHistory 判断一条日报是否被撤销过提交,用于识别“重新提交”。 func hasWithdrawHistory(tid, reportID uint64) bool { n, err := models.Orm.QueryTable(new(models.OaDailyReportHistory)). Filter("tid", tid).Filter("report_id", reportID).Filter("action", 1). Count() return err == nil && n > 0 } // loadHistories 读取一条日报的全部修改痕迹,按写入顺序返回。 func loadHistories(tid, reportID uint64) ([]HistoryItem, error) { var rows []models.OaDailyReportHistory if _, err := models.Orm.QueryTable(new(models.OaDailyReportHistory)). Filter("tid", tid).Filter("report_id", reportID). OrderBy("id").All(&rows); err != nil { return nil, err } out := make([]HistoryItem, 0, len(rows)) for i := range rows { out = append(out, buildHistory(&rows[i])) } return out, nil } func buildHistory(h *models.OaDailyReportHistory) HistoryItem { attachments := make([]Attachment, 0) if strings.TrimSpace(h.Attachments) != "" { _ = json.Unmarshal([]byte(h.Attachments), &attachments) } return HistoryItem{ ID: h.ID, Action: h.Action, UserName: h.UserName, TodayDone: h.TodayDone, TomorrowPlan: h.TomorrowPlan, Issues: h.Issues, Attachments: attachments, SubmitTime: formatTime(h.SubmitTime), CreateTime: h.CreateTime.Format("2006-01-02 15:04"), } } func buildItem(r *models.OaDailyReport, commentCount int) *ReportItem { attachments := make([]Attachment, 0) if strings.TrimSpace(r.Attachments) != "" { _ = json.Unmarshal([]byte(r.Attachments), &attachments) } return &ReportItem{ ID: r.ID, UserID: r.UserID, UserName: r.UserName, OrgID: r.OrgID, OrgName: r.OrgName, ReportDate: dateOnly(r.ReportDate), TodayDone: r.TodayDone, TomorrowPlan: r.TomorrowPlan, Issues: r.Issues, Attachments: attachments, Status: r.Status, SubmitTime: formatTime(r.SubmitTime), CreateTime: r.CreateTime.Format("2006-01-02 15:04"), UpdateTime: formatTime(r.UpdateTime), CommentCount: commentCount, } } func formatTime(t *time.Time) string { if t == nil || t.IsZero() { return "" } return t.Format("2006-01-02 15:04") } // normalizeAttachments 校验并规范化附件 JSON,最多 20 个。 func normalizeAttachments(raw string) (string, []Attachment, error) { raw = strings.TrimSpace(raw) if raw == "" || raw == "null" { return "[]", []Attachment{}, nil } var items []Attachment if err := json.Unmarshal([]byte(raw), &items); err != nil { return "", nil, errBad("附件数据格式错误") } out := make([]Attachment, 0, len(items)) for _, it := range items { if strings.TrimSpace(it.URL) == "" { continue } if len(out) >= 20 { return "", nil, errBad("附件最多20个") } out = append(out, it) } data, _ := json.Marshal(out) return string(data), out, nil } func optionalDate(v string) (string, error) { v = strings.TrimSpace(v) if v == "" { return "", nil } return ParseDate(v) } // countWhere 依次应用列值条件后计数。 func countWhere(qs orm.QuerySeter, kv ...string) int64 { for i := 0; i+1 < len(kv); i += 2 { qs = qs.Filter(kv[i], kv[i+1]) } n, _ := qs.Count() return n } func commentCount(tid, reportID uint64) int { counts := commentCounts(tid, []uint64{reportID}) return counts[reportID] } // commentCounts 批量统计评论数,避免列表逐条查询。 func commentCounts(tid uint64, ids []uint64) map[uint64]int { out := make(map[uint64]int, len(ids)) if len(ids) == 0 { return out } holders := make([]string, 0, len(ids)) args := make([]interface{}, 0, len(ids)+1) args = append(args, tid) for _, id := range ids { holders = append(holders, "?") args = append(args, id) } var rows []struct { ReportID uint64 `orm:"column(report_id)"` Cnt int `orm:"column(cnt)"` } sql := "SELECT report_id, COUNT(*) AS cnt FROM yz_backend_oa_daily_report_comment" + " WHERE tid = ? AND report_id IN (" + strings.Join(holders, ",") + ") AND is_deleted = 0 GROUP BY report_id" if _, err := models.Orm.Raw(sql, args...).QueryRows(&rows); err != nil { return out } for _, r := range rows { out[r.ReportID] = r.Cnt } return out }