Files
yunzerwebsiteallinone/go/controllers/app_oa.go
T
2026-10-19 17:34:11 +08:00

721 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package controllers
import (
"sort"
"strconv"
"strings"
"time"
"server/models"
"server/pkg/identity"
"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
}
// 只放行移动端与租户端令牌:/api/** 仅被 uniapp 调用,平台令牌不该能打移动端接口。
func (c *AppOaController) oaClaims() (*jwtutil.Claims, error) {
return identity.Claims(c.Ctx.Request.Header.Get("Authorization"), identity.EndApp, identity.EndBackend)
}
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(identity.Status(err), identity.Status(err), err.Error())
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(identity.Status(err), identity.Status(err), err.Error())
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(identity.Status(err), identity.Status(err), err.Error())
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(identity.Status(err), identity.Status(err), err.Error())
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(identity.Status(err), identity.Status(err), err.Error())
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(identity.Status(err), identity.Status(err), err.Error())
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(identity.Status(err), identity.Status(err), err.Error())
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(identity.Status(err), identity.Status(err), err.Error())
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(identity.Status(err), identity.Status(err), err.Error())
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(identity.Status(err), identity.Status(err), err.Error())
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(identity.Status(err), identity.Status(err), err.Error())
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{}{}
paidIDList := make([]uint64, 0, len(paidIDs))
for id := range paidIDs {
paidIDList = append(paidIDList, id)
}
var items []models.BackendReimbursementItem
if len(paidIDList) > 0 {
// 按已打款主单 ID 查明细,不依赖明细行的 tid/uid(历史数据可能不一致)
if _, err := models.Orm.QueryTable(new(models.BackendReimbursementItem)).
Filter("reimbursement_id__in", paidIDList).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,
}
}