727 lines
22 KiB
Go
727 lines
22 KiB
Go
package controllers
|
||
|
||
import (
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"server/models"
|
||
"server/pkg/jwtutil"
|
||
"server/services"
|
||
|
||
"github.com/beego/beego/v2/client/orm"
|
||
beego "github.com/beego/beego/v2/server/web"
|
||
)
|
||
|
||
// AppOaController 移动端 OA 协同办公接口(通知公告 / 文档库 / 薪酬工资单 / 员工档案)。
|
||
//
|
||
// 与 Web 管理端(/backend/oa/*)的区别:
|
||
// - 鉴权放宽到 app 令牌(backend/app/platform),供 uniapp 调用;
|
||
// - 只提供读能力(浏览 / 下载 / 收藏);写入操作仍留在 Web 端;
|
||
// - 薪酬工资单与员工档案仅返回「本人」数据,通过登录账号匹配员工。
|
||
//
|
||
// 数据隔离:一律以 JWT 中的 TenantId 过滤,前端无法指定租户。
|
||
type AppOaController struct {
|
||
beego.Controller
|
||
}
|
||
|
||
func (c *AppOaController) oaClaims() (*jwtutil.Claims, error) {
|
||
auth := c.Ctx.Request.Header.Get("Authorization")
|
||
if auth == "" {
|
||
return nil, orm.ErrNoRows
|
||
}
|
||
parts := strings.SplitN(auth, " ", 2)
|
||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||
return nil, orm.ErrNoRows
|
||
}
|
||
claims, err := jwtutil.ParseToken(parts[1])
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if claims.UserType != "backend" && claims.UserType != "app" && claims.UserType != "platform" {
|
||
return nil, orm.ErrNoRows
|
||
}
|
||
return claims, nil
|
||
}
|
||
|
||
func (c *AppOaController) oaErr(httpStatus, bizCode int, msg string) {
|
||
c.Ctx.Output.SetStatus(httpStatus)
|
||
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
|
||
_ = c.ServeJSON()
|
||
}
|
||
|
||
func (c *AppOaController) oaOk(data interface{}) {
|
||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data}
|
||
_ = c.ServeJSON()
|
||
}
|
||
|
||
// oaMyEmployee 解析当前登录用户对应的员工记录(tid + 账号匹配,口径与 backend_auth 一致)。
|
||
func (c *AppOaController) oaMyEmployee(claims *jwtutil.Claims) (*models.BackendEmployee, bool) {
|
||
uid := services.ResolveTenantUID(uint64(claims.TenantId), uint64(claims.UserID))
|
||
account := strings.TrimSpace(claims.Username)
|
||
var tu models.SystemTenantUser
|
||
if models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||
Filter("uid", uid).Filter("tid", claims.TenantId).
|
||
Filter("delete_time__isnull", true).One(&tu) == nil {
|
||
if tu.Account != nil && strings.TrimSpace(*tu.Account) != "" {
|
||
account = strings.TrimSpace(*tu.Account)
|
||
}
|
||
}
|
||
if account == "" {
|
||
return nil, false
|
||
}
|
||
var emp models.BackendEmployee
|
||
if models.Orm.QueryTable(new(models.BackendEmployee)).
|
||
Filter("tid", claims.TenantId).Filter("account", account).
|
||
Filter("delete_time__isnull", true).One(&emp) != nil {
|
||
return nil, false
|
||
}
|
||
return &emp, true
|
||
}
|
||
|
||
// ---------------- 通知公告 ----------------
|
||
|
||
// noticeItem 公告列表项(正文只回传摘要,避免列表过大)。
|
||
func noticeItem(n models.OaNotice) map[string]interface{} {
|
||
publishTime := ""
|
||
if n.PublishTime != nil {
|
||
publishTime = n.PublishTime.Format("2006-01-02 15:04:05")
|
||
}
|
||
return map[string]interface{}{
|
||
"id": n.ID,
|
||
"title": n.Title,
|
||
"notice_type": n.NoticeType,
|
||
"level": n.Level,
|
||
"is_top": n.IsTop,
|
||
"publisher_name": n.PublisherName,
|
||
"publish_time": publishTime,
|
||
"read_count": n.ReadCount,
|
||
"summary": oaTextSummary(n.Content, 60),
|
||
}
|
||
}
|
||
|
||
// oaTextSummary 取正文纯文本摘要,按「字符数」截断(中文安全)。
|
||
func oaTextSummary(content string, limit int) string {
|
||
text := strings.Join(strings.Fields(content), " ")
|
||
runes := []rune(text)
|
||
if limit > 0 && len(runes) > limit {
|
||
return string(runes[:limit]) + "…"
|
||
}
|
||
return text
|
||
}
|
||
|
||
// NoticeList GET /app/oa/notice/list 仅返回已发布(status=1)的公告。
|
||
func (c *AppOaController) NoticeList() {
|
||
claims, err := c.oaClaims()
|
||
if err != nil {
|
||
c.oaErr(401, 401, "未登录或无权限")
|
||
return
|
||
}
|
||
|
||
page, _ := c.GetInt("page", 1)
|
||
pageSize, _ := c.GetInt("pageSize", 20)
|
||
if page < 1 {
|
||
page = 1
|
||
}
|
||
if pageSize < 1 || pageSize > 100 {
|
||
pageSize = 20
|
||
}
|
||
keyword := strings.TrimSpace(c.GetString("keyword"))
|
||
|
||
qs := models.Orm.QueryTable(new(models.OaNotice)).
|
||
Filter("is_deleted", 0).
|
||
Filter("tid", claims.TenantId).
|
||
Filter("status", 1)
|
||
if keyword != "" {
|
||
qs = qs.Filter("title__icontains", keyword)
|
||
}
|
||
|
||
total, _ := qs.Count()
|
||
|
||
var rows []models.OaNotice
|
||
_, err = qs.OrderBy("-is_top", "-publish_time", "-id").
|
||
Limit(pageSize).Offset((page - 1) * pageSize).All(&rows)
|
||
if err != nil && err != orm.ErrNoRows {
|
||
c.oaErr(500, 500, "查询失败")
|
||
return
|
||
}
|
||
list := make([]map[string]interface{}, 0, len(rows))
|
||
for _, n := range rows {
|
||
list = append(list, noticeItem(n))
|
||
}
|
||
|
||
c.oaOk(map[string]interface{}{"list": list, "total": total, "page": page, "pageSize": pageSize})
|
||
}
|
||
|
||
// NoticeDetail GET /app/oa/notice/:id 公告详情,同时累加阅读数。
|
||
func (c *AppOaController) NoticeDetail() {
|
||
claims, err := c.oaClaims()
|
||
if err != nil {
|
||
c.oaErr(401, 401, "未登录或无权限")
|
||
return
|
||
}
|
||
|
||
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||
if id == 0 {
|
||
c.oaErr(400, 400, "无效的公告ID")
|
||
return
|
||
}
|
||
|
||
var n models.OaNotice
|
||
if err := models.Orm.QueryTable(new(models.OaNotice)).
|
||
Filter("id", id).Filter("tid", claims.TenantId).
|
||
Filter("is_deleted", 0).Filter("status", 1).One(&n); err != nil {
|
||
c.oaErr(404, 404, "公告不存在")
|
||
return
|
||
}
|
||
|
||
_, _ = models.Orm.Raw(
|
||
"UPDATE yz_backend_oa_notice SET read_count = read_count + 1 WHERE id = ?", id).Exec()
|
||
n.ReadCount++
|
||
|
||
item := noticeItem(n)
|
||
item["content"] = n.Content
|
||
c.oaOk(item)
|
||
}
|
||
|
||
// ---------------- 文档库 ----------------
|
||
|
||
// oaDocActor 由 JWT 构造文档访问者上下文(租户 + 用户可见性)。
|
||
func (c *AppOaController) oaDocActor(claims *jwtutil.Claims) services.OaDocActor {
|
||
return services.NewOaDocActor(claims.TenantId, uint64(claims.UserID))
|
||
}
|
||
|
||
// docItem 文档列表/详情项(不回传体积较大的在线编辑源)。
|
||
func docItem(d models.OaDoc) map[string]interface{} {
|
||
updateTime := ""
|
||
if d.UpdateTime != nil {
|
||
updateTime = d.UpdateTime.Format("2006-01-02 15:04:05")
|
||
}
|
||
return map[string]interface{}{
|
||
"id": d.ID,
|
||
"title": d.Title,
|
||
"category_id": d.CategoryID,
|
||
"file_url": d.FileURL,
|
||
"file_name": d.FileName,
|
||
"ext": d.Ext,
|
||
"size": d.Size,
|
||
"doc_type": d.DocType,
|
||
"tags": d.Tags,
|
||
"summary": d.Summary,
|
||
"is_online": d.IsOnline,
|
||
"status": d.Status,
|
||
"version": d.Version,
|
||
"is_star": d.IsStar,
|
||
"owner_name": d.OwnerName,
|
||
"creator_name": d.CreatorName,
|
||
"view_count": d.ViewCount,
|
||
"download_count": d.DownCount,
|
||
"visibility": d.Visibility,
|
||
"create_time": d.CreateTime.Format("2006-01-02 15:04:05"),
|
||
"update_time": updateTime,
|
||
}
|
||
}
|
||
|
||
// DocCategories GET /app/oa/document/categories?scope=shared|personal|all 分类树(含数量)。
|
||
func (c *AppOaController) DocCategories() {
|
||
claims, err := c.oaClaims()
|
||
if err != nil {
|
||
c.oaErr(401, 401, "未登录或无权限")
|
||
return
|
||
}
|
||
tree, err := services.OaDocCategoryTree(c.oaDocActor(claims), strings.TrimSpace(c.GetString("scope")))
|
||
if err != nil {
|
||
c.oaErr(400, 400, err.Error())
|
||
return
|
||
}
|
||
c.oaOk(tree)
|
||
}
|
||
|
||
// DocList GET /app/oa/document/list 文档分页(按可见性 + 空间过滤)。
|
||
func (c *AppOaController) DocList() {
|
||
claims, err := c.oaClaims()
|
||
if err != nil {
|
||
c.oaErr(401, 401, "未登录或无权限")
|
||
return
|
||
}
|
||
|
||
page, _ := c.GetInt("page", 1)
|
||
pageSize, _ := c.GetInt("pageSize", 20)
|
||
if page < 1 {
|
||
page = 1
|
||
}
|
||
if pageSize < 1 || pageSize > 100 {
|
||
pageSize = 20
|
||
}
|
||
status, _ := c.GetInt("status", -1)
|
||
docType, _ := c.GetInt("doc_type", -1)
|
||
star, _ := c.GetInt("star", -1)
|
||
categoryID, _ := c.GetUint64("category_id")
|
||
|
||
actor := c.oaDocActor(claims)
|
||
list, total, err := services.OaDocList(actor, services.OaDocListParams{
|
||
Actor: actor,
|
||
Scope: strings.TrimSpace(c.GetString("scope")),
|
||
Keyword: strings.TrimSpace(c.GetString("keyword")),
|
||
CategoryID: categoryID,
|
||
Uncategorized: strings.TrimSpace(c.GetString("uncategorized")) == "1",
|
||
Visibility: -1,
|
||
Mine: strings.TrimSpace(c.GetString("mine")) == "1",
|
||
Status: status,
|
||
DocType: docType,
|
||
Star: star,
|
||
Page: page,
|
||
PageSize: pageSize,
|
||
})
|
||
if err != nil {
|
||
c.oaErr(400, 400, err.Error())
|
||
return
|
||
}
|
||
|
||
items := make([]map[string]interface{}, 0, len(list))
|
||
for _, d := range list {
|
||
items = append(items, docItem(d))
|
||
}
|
||
c.oaOk(map[string]interface{}{"list": items, "total": total, "page": page, "pageSize": pageSize})
|
||
}
|
||
|
||
// DocDetail GET /app/oa/document/:id 文档详情(累加查看数)。
|
||
func (c *AppOaController) DocDetail() {
|
||
claims, err := c.oaClaims()
|
||
if err != nil {
|
||
c.oaErr(401, 401, "未登录或无权限")
|
||
return
|
||
}
|
||
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||
if id == 0 {
|
||
c.oaErr(400, 400, "无效的文档ID")
|
||
return
|
||
}
|
||
doc, err := services.OaDocView(c.oaDocActor(claims), id)
|
||
if err != nil {
|
||
c.oaErr(404, 404, err.Error())
|
||
return
|
||
}
|
||
if strings.TrimSpace(doc.Content) != "" {
|
||
doc.IsOnline = 1
|
||
}
|
||
c.oaOk(docItem(*doc))
|
||
}
|
||
|
||
// DocDownload GET /app/oa/document/download/:id 记录下载并返回下载地址。
|
||
func (c *AppOaController) DocDownload() {
|
||
claims, err := c.oaClaims()
|
||
if err != nil {
|
||
c.oaErr(401, 401, "未登录或无权限")
|
||
return
|
||
}
|
||
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||
if id == 0 {
|
||
c.oaErr(400, 400, "无效的文档ID")
|
||
return
|
||
}
|
||
doc, err := services.OaDocDownload(c.oaDocActor(claims), id)
|
||
if err != nil {
|
||
c.oaErr(404, 404, err.Error())
|
||
return
|
||
}
|
||
c.oaOk(map[string]interface{}{
|
||
"id": doc.ID,
|
||
"title": doc.Title,
|
||
"file_url": doc.FileURL,
|
||
"file_name": doc.FileName,
|
||
"download_count": doc.DownCount,
|
||
})
|
||
}
|
||
|
||
// DocStar POST /app/oa/document/:id/star 收藏 / 取消收藏。
|
||
func (c *AppOaController) DocStar() {
|
||
claims, err := c.oaClaims()
|
||
if err != nil {
|
||
c.oaErr(401, 401, "未登录或无权限")
|
||
return
|
||
}
|
||
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||
if id == 0 {
|
||
c.oaErr(400, 400, "无效的文档ID")
|
||
return
|
||
}
|
||
isStar, err := services.OaDocToggleStar(c.oaDocActor(claims), id)
|
||
if err != nil {
|
||
c.oaErr(400, 400, err.Error())
|
||
return
|
||
}
|
||
c.oaOk(map[string]interface{}{"is_star": isStar})
|
||
}
|
||
|
||
// ---------------- 薪酬工资单(仅本人) ----------------
|
||
|
||
// PayrollList GET /app/oa/compensation/payrolls 仅返回当前登录用户本人的薪资单。
|
||
func (c *AppOaController) PayrollList() {
|
||
claims, err := c.oaClaims()
|
||
if err != nil {
|
||
c.oaErr(401, 401, "未登录或无权限")
|
||
return
|
||
}
|
||
|
||
page, _ := c.GetInt("page", 1)
|
||
pageSize, _ := c.GetInt("pageSize", 20)
|
||
if page < 1 {
|
||
page = 1
|
||
}
|
||
if pageSize < 1 || pageSize > 100 {
|
||
pageSize = 20
|
||
}
|
||
|
||
emp, ok := c.oaMyEmployee(claims)
|
||
if !ok {
|
||
c.oaOk(map[string]interface{}{"list": []map[string]interface{}{}, "total": 0})
|
||
return
|
||
}
|
||
|
||
qs := models.Orm.QueryTable(new(models.BackendOaPayroll)).
|
||
Filter("tid", claims.TenantId).
|
||
Filter("employee_id", emp.ID).
|
||
Filter("is_deleted", 0)
|
||
if month := strings.TrimSpace(c.GetString("payroll_month")); compensationMonthPattern.MatchString(month) {
|
||
qs = qs.Filter("payroll_month", month)
|
||
}
|
||
|
||
total, _ := qs.Count()
|
||
|
||
var rows []models.BackendOaPayroll
|
||
if _, err := qs.OrderBy("-payroll_month", "-id").
|
||
Limit(pageSize).Offset((page - 1) * pageSize).All(&rows); err != nil && err != orm.ErrNoRows {
|
||
c.oaErr(500, 500, "查询失败")
|
||
return
|
||
}
|
||
list := make([]map[string]interface{}, 0, len(rows))
|
||
for _, row := range rows {
|
||
list = append(list, payrollMap(row))
|
||
}
|
||
|
||
c.oaOk(map[string]interface{}{"list": list, "total": total, "page": page, "pageSize": pageSize})
|
||
}
|
||
|
||
// PayrollDetail GET /app/oa/compensation/payrolls/:id 本人薪资单明细(含自定义增减项)。
|
||
func (c *AppOaController) PayrollDetail() {
|
||
claims, err := c.oaClaims()
|
||
if err != nil {
|
||
c.oaErr(401, 401, "未登录或无权限")
|
||
return
|
||
}
|
||
|
||
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||
if id == 0 {
|
||
c.oaErr(400, 400, "无效的薪资单ID")
|
||
return
|
||
}
|
||
|
||
emp, ok := c.oaMyEmployee(claims)
|
||
if !ok {
|
||
c.oaErr(404, 404, "未找到您的员工信息")
|
||
return
|
||
}
|
||
|
||
var row models.BackendOaPayroll
|
||
if models.Orm.QueryTable(new(models.BackendOaPayroll)).
|
||
Filter("id", id).Filter("tid", claims.TenantId).
|
||
Filter("employee_id", emp.ID).Filter("is_deleted", 0).One(&row) != nil {
|
||
c.oaErr(404, 404, "薪资单不存在")
|
||
return
|
||
}
|
||
|
||
var items []models.BackendOaPayrollItem
|
||
_, _ = models.Orm.QueryTable(new(models.BackendOaPayrollItem)).
|
||
Filter("tid", claims.TenantId).Filter("payroll_id", id).
|
||
Filter("is_deleted", 0).OrderBy("sort_order", "id").All(&items)
|
||
if items == nil {
|
||
items = []models.BackendOaPayrollItem{}
|
||
}
|
||
|
||
data := payrollMap(row)
|
||
data["items"] = items
|
||
c.oaOk(data)
|
||
}
|
||
|
||
// ---------------- 员工档案(仅本人) ----------------
|
||
|
||
// MyEmployeeFile GET /app/oa/employee-file 返回当前登录用户本人的档案与子记录(只读)。
|
||
func (c *AppOaController) MyEmployeeFile() {
|
||
claims, err := c.oaClaims()
|
||
if err != nil {
|
||
c.oaErr(401, 401, "未登录或无权限")
|
||
return
|
||
}
|
||
|
||
emp, ok := c.oaMyEmployee(claims)
|
||
if !ok {
|
||
c.oaErr(404, 404, "未找到您的员工信息")
|
||
return
|
||
}
|
||
tid := claims.TenantId
|
||
|
||
var file models.BackendEmployeeFile
|
||
if models.Orm.QueryTable(new(models.BackendEmployeeFile)).
|
||
Filter("tid", tid).Filter("is_deleted", 0).
|
||
Filter("employee_id", emp.ID).One(&file) != nil {
|
||
c.oaErr(404, 404, "您暂无员工档案")
|
||
return
|
||
}
|
||
|
||
var records []models.BackendEmployeeFileRecord
|
||
_, _ = models.Orm.QueryTable(new(models.BackendEmployeeFileRecord)).
|
||
Filter("tid", tid).Filter("is_deleted", 0).
|
||
Filter("employee_id", emp.ID).OrderBy("sort", "id").All(&records)
|
||
if records == nil {
|
||
records = []models.BackendEmployeeFileRecord{}
|
||
}
|
||
|
||
dtos := (&BackendEmployeeFileController{}).efAssembleDTOList(tid, []models.BackendEmployeeFile{file})
|
||
|
||
c.oaOk(map[string]interface{}{"file": dtos[0], "records": records})
|
||
}
|
||
|
||
// ---------------- 工作台仪表盘(移动端首屏聚合) ----------------
|
||
|
||
// Dashboard GET /app/oa/dashboard
|
||
//
|
||
// 一次返回移动端 OA 首页顶部仪表盘所需的全部数据,口径对齐 Web 端
|
||
// /backend/oa/schedule/stats 与 /backend/reimbursements/dashboard:
|
||
//
|
||
// {
|
||
// schedule_stats:{today_total,today_done,week_total,week_done,month_total,month_done,overdue},
|
||
// reimburse:{week,month,year,trend,status_dist,expense_types},
|
||
// notices:[...], // 最近已发布公告(置顶优先,最多 5 条)
|
||
// today_schedules:[...] // 今日日程(含已完成,灰显沉底)
|
||
// }
|
||
func (c *AppOaController) Dashboard() {
|
||
claims, err := c.oaClaims()
|
||
if err != nil {
|
||
c.oaErr(401, 401, "未登录或无权限")
|
||
return
|
||
}
|
||
uid := uint64(claims.UserID)
|
||
|
||
c.oaOk(map[string]interface{}{
|
||
"schedule_stats": c.oaDashboardScheduleStats(claims.TenantId, uid),
|
||
"reimburse": c.oaDashboardReimburse(uint64(claims.TenantId), uid),
|
||
"notices": c.oaDashboardNotices(claims.TenantId),
|
||
"today_schedules": c.oaDashboardTodaySchedules(claims.TenantId, uid),
|
||
})
|
||
}
|
||
|
||
// oaDashboardScheduleStats 今日/本周/本月日程统计与逾期数(与 Web 端 Stats 同口径)。
|
||
func (c *AppOaController) oaDashboardScheduleStats(tid int, uid uint64) map[string]interface{} {
|
||
now := time.Now()
|
||
today := now.Format("2006-01-02")
|
||
weekday := int(now.Weekday())
|
||
if weekday == 0 {
|
||
weekday = 7
|
||
}
|
||
weekStart := now.AddDate(0, 0, -(weekday - 1)).Format("2006-01-02")
|
||
weekEnd := now.AddDate(0, 0, 7-weekday).Format("2006-01-02")
|
||
monthStart := now.Format("2006-01") + "-01"
|
||
|
||
base := func() orm.QuerySeter {
|
||
return models.Orm.QueryTable(new(models.OaSchedule)).
|
||
Filter("is_deleted", 0).
|
||
Filter("tid", tid).
|
||
Filter("user_id", uid)
|
||
}
|
||
|
||
todayTotal, _ := base().Filter("schedule_date", today).Count()
|
||
todayDone, _ := base().Filter("schedule_date", today).Filter("status", 1).Count()
|
||
weekTotal, _ := base().Filter("schedule_date__gte", weekStart).Filter("schedule_date__lte", weekEnd).Count()
|
||
weekDone, _ := base().Filter("schedule_date__gte", weekStart).Filter("schedule_date__lte", weekEnd).Filter("status", 1).Count()
|
||
monthTotal, _ := base().Filter("schedule_date__gte", monthStart).Count()
|
||
monthDone, _ := base().Filter("schedule_date__gte", monthStart).Filter("status", 1).Count()
|
||
overdue, _ := base().Filter("schedule_date__lt", today).Filter("status", 0).Count()
|
||
|
||
return map[string]interface{}{
|
||
"today_total": todayTotal,
|
||
"today_done": todayDone,
|
||
"week_total": weekTotal,
|
||
"week_done": weekDone,
|
||
"month_total": monthTotal,
|
||
"month_done": monthDone,
|
||
"overdue": overdue,
|
||
}
|
||
}
|
||
|
||
// oaDashboardTodaySchedules 今日日程列表(未完成在前,已完成灰显沉底)。
|
||
func (c *AppOaController) oaDashboardTodaySchedules(tid int, uid uint64) []map[string]interface{} {
|
||
today := time.Now().Format("2006-01-02")
|
||
var rows []models.OaSchedule
|
||
_, _ = models.Orm.QueryTable(new(models.OaSchedule)).
|
||
Filter("is_deleted", 0).Filter("tid", tid).Filter("user_id", uid).
|
||
Filter("schedule_date", today).
|
||
OrderBy("status", "start_time", "id").Limit(50).All(&rows)
|
||
out := make([]map[string]interface{}, 0, len(rows))
|
||
for _, s := range rows {
|
||
out = append(out, map[string]interface{}{
|
||
"id": s.ID,
|
||
"title": s.Title,
|
||
"schedule_date": s.ScheduleDate,
|
||
"start_time": s.StartTime,
|
||
"end_time": s.EndTime,
|
||
"all_day": s.AllDay,
|
||
"color": s.Color,
|
||
"priority": s.Priority,
|
||
"status": s.Status,
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// oaDashboardNotices 最近已发布公告(置顶优先,最多 5 条)。
|
||
func (c *AppOaController) oaDashboardNotices(tid int) []map[string]interface{} {
|
||
var rows []models.OaNotice
|
||
_, _ = models.Orm.QueryTable(new(models.OaNotice)).
|
||
Filter("is_deleted", 0).Filter("tid", tid).Filter("status", 1).
|
||
OrderBy("-is_top", "-publish_time", "-id").Limit(5).All(&rows)
|
||
out := make([]map[string]interface{}, 0, len(rows))
|
||
for _, n := range rows {
|
||
out = append(out, noticeItem(n))
|
||
}
|
||
return out
|
||
}
|
||
|
||
// oaDashboardReimburse 报销统计(周/月/年 + 近6月趋势 + 状态分布 + 类型分析),
|
||
// 个人报销单量小,一次全量查询内存聚合(与 Web 端 Dashboard 同口径)。
|
||
func (c *AppOaController) oaDashboardReimburse(tid, uid uint64) map[string]interface{} {
|
||
now := time.Now()
|
||
y, m, _ := now.Date()
|
||
today := time.Date(y, m, now.Day(), 0, 0, 0, 0, now.Location())
|
||
tomorrow := today.AddDate(0, 0, 1)
|
||
weekday := int(today.Weekday())
|
||
if weekday == 0 {
|
||
weekday = 7
|
||
}
|
||
weekStart := today.AddDate(0, 0, -(weekday - 1))
|
||
monthStart := time.Date(y, m, 1, 0, 0, 0, 0, now.Location())
|
||
yearStart := time.Date(y, 1, 1, 0, 0, 0, 0, now.Location())
|
||
|
||
newStat := func() map[string]interface{} {
|
||
return map[string]interface{}{"total": 0.0, "paid": 0.0, "unpaid": 0.0, "count": 0}
|
||
}
|
||
addTo := func(stat map[string]interface{}, row *models.BackendReimbursement) {
|
||
stat["total"] = stat["total"].(float64) + row.TotalAmount
|
||
stat["count"] = stat["count"].(int) + 1
|
||
if row.Status == 5 {
|
||
stat["paid"] = stat["paid"].(float64) + row.TotalAmount
|
||
} else {
|
||
stat["unpaid"] = stat["unpaid"].(float64) + row.TotalAmount
|
||
}
|
||
}
|
||
inRange := func(t, start, end time.Time) bool {
|
||
return !t.Before(start) && t.Before(end)
|
||
}
|
||
|
||
weekStat, monthStat, yearStat := newStat(), newStat(), newStat()
|
||
monthKeys := make([]string, 0, 6)
|
||
monthStats := map[string]map[string]interface{}{}
|
||
for i := 5; i >= 0; i-- {
|
||
key := time.Date(y, m-time.Month(i), 1, 0, 0, 0, 0, now.Location()).Format("2006-01")
|
||
monthKeys = append(monthKeys, key)
|
||
monthStats[key] = newStat()
|
||
}
|
||
statusNames := map[int8]string{0: "草稿", 1: "审批中", 2: "已通过", 3: "已驳回", 4: "已撤回", 5: "已打款"}
|
||
statusDist := map[string]interface{}{}
|
||
|
||
var rows []models.BackendReimbursement
|
||
if _, err := models.Orm.QueryTable(new(models.BackendReimbursement)).
|
||
Filter("tid", tid).Filter("user_id", uid).Filter("is_deleted", 0).
|
||
All(&rows); err != nil {
|
||
return map[string]interface{}{
|
||
"week": weekStat, "month": monthStat, "year": yearStat,
|
||
"trend": []map[string]interface{}{}, "status_dist": statusDist, "expense_types": []map[string]interface{}{},
|
||
}
|
||
}
|
||
|
||
for i := range rows {
|
||
row := &rows[i]
|
||
created := row.CreateTime
|
||
if inRange(created, weekStart, tomorrow) {
|
||
addTo(weekStat, row)
|
||
}
|
||
if inRange(created, monthStart, tomorrow) {
|
||
addTo(monthStat, row)
|
||
}
|
||
if inRange(created, yearStart, tomorrow) {
|
||
addTo(yearStat, row)
|
||
}
|
||
if st, ok := monthStats[created.Format("2006-01")]; ok {
|
||
addTo(st, row)
|
||
}
|
||
sk := strconv.Itoa(int(row.Status))
|
||
dist, ok := statusDist[sk].(map[string]interface{})
|
||
if !ok {
|
||
dist = map[string]interface{}{"name": statusNames[row.Status], "count": 0, "amount": 0.0}
|
||
statusDist[sk] = dist
|
||
}
|
||
dist["count"] = dist["count"].(int) + 1
|
||
dist["amount"] = dist["amount"].(float64) + row.TotalAmount
|
||
}
|
||
|
||
trend := make([]map[string]interface{}, 0, len(monthKeys))
|
||
for _, key := range monthKeys {
|
||
st := monthStats[key]
|
||
trend = append(trend, map[string]interface{}{
|
||
"month": key, "total": st["total"], "paid": st["paid"],
|
||
"unpaid": st["unpaid"], "count": st["count"],
|
||
})
|
||
}
|
||
|
||
// 报销类型分析:仅统计已打款(status=5)的报销单费用明细
|
||
paidIDs := map[uint64]bool{}
|
||
for i := range rows {
|
||
if rows[i].Status == 5 {
|
||
paidIDs[rows[i].ID] = true
|
||
}
|
||
}
|
||
expenseTypes := []map[string]interface{}{}
|
||
var items []models.BackendReimbursementItem
|
||
if _, err := models.Orm.QueryTable(new(models.BackendReimbursementItem)).
|
||
Filter("tid", tid).Filter("uid", uid).Filter("is_deleted", 0).
|
||
All(&items); err == nil {
|
||
typeAgg := map[string]*struct {
|
||
count int
|
||
amount float64
|
||
}{}
|
||
for i := range items {
|
||
if !paidIDs[items[i].ReimbursementID] {
|
||
continue
|
||
}
|
||
name := strings.TrimSpace(items[i].ExpenseType)
|
||
if name == "" {
|
||
name = "未分类"
|
||
}
|
||
agg, ok := typeAgg[name]
|
||
if !ok {
|
||
agg = &struct {
|
||
count int
|
||
amount float64
|
||
}{}
|
||
typeAgg[name] = agg
|
||
}
|
||
agg.count++
|
||
agg.amount += items[i].Amount
|
||
}
|
||
for name, agg := range typeAgg {
|
||
expenseTypes = append(expenseTypes, map[string]interface{}{
|
||
"name": name, "count": agg.count, "amount": agg.amount,
|
||
})
|
||
}
|
||
sort.Slice(expenseTypes, func(i, j int) bool {
|
||
return expenseTypes[i]["amount"].(float64) > expenseTypes[j]["amount"].(float64)
|
||
})
|
||
}
|
||
|
||
return map[string]interface{}{
|
||
"week": weekStat, "month": monthStat, "year": yearStat,
|
||
"trend": trend, "status_dist": statusDist, "expense_types": expenseTypes,
|
||
}
|
||
}
|