Files
yunzerwebsiteallinone/go/controllers/backend_reimburse.go
T

2148 lines
68 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 (
"encoding/json"
"fmt"
"io"
"path"
"sort"
"strconv"
"strings"
"time"
"server/models"
"server/pkg/identity"
"server/pkg/jwtutil"
"server/services"
approvalEngine "server/services/approval"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
)
// BackendReimburseController 报销及审批接口。
type BackendReimburseController struct {
beego.Controller
}
type reimbursementItemPayload struct {
ID uint64 `json:"id"`
ExpenseType string `json:"expense_type"`
ExpenseDate string `json:"expense_date"`
Amount float64 `json:"amount"`
Quantity int `json:"quantity"`
UnitPrice float64 `json:"unit_price"`
Description string `json:"description"`
ReceiptFlag int8 `json:"receipt_flag"`
ReceiptFileIDs string `json:"receipt_file_ids"`
ReceiptURLs string `json:"receipt_urls"`
ReceiptAmounts *string `json:"receipt_amounts,omitempty"`
// 发票挂靠:用指针区分「未传」与「传 0」。上传发票、保存票面金额等调用
// 不带这两个字段,此时必须保留明细原有的挂靠关系;只有显式传 0 才是解挂。
InvoiceFileID *uint64 `json:"invoice_file_id"`
InvoiceUseAmount *float64 `json:"invoice_use_amount"`
}
type reimbursementPayload struct {
DepartmentID *uint64 `json:"department_id"`
ApplyDate string `json:"apply_date"`
Description string `json:"description"`
Items []reimbursementItemPayload `json:"items"`
}
func (c *BackendReimburseController) claims() (*jwtutil.Claims, bool) {
claims, err := identity.Claims(c.Ctx.Request.Header.Get("Authorization"), identity.EndBackend)
if err != nil || claims.TenantId <= 0 || claims.UserID <= 0 {
return nil, false
}
return claims, true
}
func (c *BackendReimburseController) reply(code int, msg string, data interface{}) {
if code != 200 {
c.Ctx.Output.SetStatus(code)
}
result := map[string]interface{}{"code": code, "msg": msg}
if data != nil {
result["data"] = data
}
c.Data["json"] = result
_ = c.ServeJSON()
}
func (c *BackendReimburseController) parsePayload() (reimbursementPayload, error) {
var payload reimbursementPayload
raw, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil {
return payload, err
}
err = json.Unmarshal(raw, &payload)
return payload, err
}
func parseReimburseDate(value string) (time.Time, error) {
return time.Parse("2006-01-02", strings.TrimSpace(value))
}
func (c *BackendReimburseController) getID() (uint64, bool) {
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
return id, err == nil && id > 0
}
// findOwnedReimbursement 按 id + 租户 + 申请人 查询本人报销单(未删除)。
// 报销单为个人单据:与列表口径一致,按 JWT 中 tid + user_id 双重隔离。
func findOwnedReimbursement(claims *jwtutil.Claims, id uint64) (*models.BackendReimbursement, error) {
row := &models.BackendReimbursement{}
err := models.Orm.QueryTable(new(models.BackendReimbursement)).
Filter("id", id).
Filter("tid", claims.TenantId).
Filter("user_id", claims.UserID).
Filter("is_deleted", 0).
One(row)
return row, err
}
func parseReceiptFileIDs(raw string) []uint64 {
ids := make([]uint64, 0)
seen := map[uint64]bool{}
for _, part := range strings.Split(raw, ",") {
id, err := strconv.ParseUint(strings.TrimSpace(part), 10, 64)
if err != nil || id == 0 || seen[id] {
continue
}
seen[id] = true
ids = append(ids, id)
}
return ids
}
func joinReceiptFileIDs(ids []uint64) string {
if len(ids) == 0 {
return ""
}
parts := make([]string, 0, len(ids))
for _, id := range ids {
parts = append(parts, strconv.FormatUint(id, 10))
}
return strings.Join(parts, ",")
}
func parseReceiptAmounts(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return []string{}
}
parts := strings.Split(raw, ",")
result := make([]string, len(parts))
for i, part := range parts {
result[i] = strings.TrimSpace(part)
}
return result
}
func joinReceiptAmounts(parts []string) string {
if len(parts) == 0 {
return ""
}
return strings.Join(parts, ",")
}
func normalizeReceiptAmounts(raw string, idCount int) string {
parts := parseReceiptAmounts(raw)
if idCount <= 0 {
return ""
}
if len(parts) < idCount {
padding := make([]string, idCount-len(parts))
parts = append(parts, padding...)
} else if len(parts) > idCount {
parts = parts[:idCount]
}
normalized := make([]string, idCount)
for i := 0; i < idCount; i++ {
part := strings.TrimSpace(parts[i])
if part == "" {
normalized[i] = ""
continue
}
value, err := strconv.ParseFloat(part, 64)
if err != nil || value < 0 {
normalized[i] = ""
continue
}
normalized[i] = fmt.Sprintf("%.2f", value)
}
return joinReceiptAmounts(normalized)
}
func remapReceiptAmounts(oldIDs []uint64, oldAmounts []string, newIDs []uint64) string {
oldMap := map[uint64]string{}
for i, id := range oldIDs {
if i < len(oldAmounts) {
oldMap[id] = oldAmounts[i]
}
}
parts := make([]string, len(newIDs))
for i, id := range newIDs {
if amount, ok := oldMap[id]; ok {
parts[i] = amount
}
}
return joinReceiptAmounts(parts)
}
// normalizeReceiptURLs 规范化逗号分隔的票据地址(历史七牛 HTTP 域名改写为存储配置入口),
// 保证 HTTPS 页面不会因 Mixed Content 拦截发票预览。
func normalizeReceiptURLs(scheme, host, raw string) string {
if strings.TrimSpace(raw) == "" {
return ""
}
parts := strings.Split(raw, ",")
for i, part := range parts {
parts[i] = services.ResolvePublicURL(scheme, host, strings.TrimSpace(part))
}
return strings.Join(parts, ",")
}
func resolveReceiptFiles(scheme, host string, item models.BackendReimbursementItem) []map[string]interface{} {
ids := parseReceiptFileIDs(item.ReceiptFileIDs)
if len(ids) == 0 {
return []map[string]interface{}{}
}
urlParts := strings.Split(item.ReceiptURLs, ",")
amountParts := parseReceiptAmounts(item.ReceiptAmounts)
files := make([]map[string]interface{}, 0, len(ids))
for i, id := range ids {
url := ""
if i < len(urlParts) {
url = services.ResolvePublicURL(scheme, host, strings.TrimSpace(urlParts[i]))
}
if url == "" {
idCopy := id
url = services.ResolveSoftwareDownloadURL(scheme, host, nil, &idCopy)
}
name := ""
var f models.SystemFile
if err := models.Orm.QueryTable(new(models.SystemFile)).
Filter("id", id).Filter("delete_time__isnull", true).One(&f); err == nil {
name = f.Name
}
if name == "" && url != "" {
name = path.Base(strings.Split(url, "?")[0])
}
var amount interface{}
if i < len(amountParts) && amountParts[i] != "" {
if value, err := strconv.ParseFloat(amountParts[i], 64); err == nil {
amount = value
}
}
files = append(files, map[string]interface{}{
"file_id": id, "url": url, "name": name, "amount": amount,
})
}
return files
}
func syncReceiptFields(item *models.BackendReimbursementItem, scheme, host string) {
files := resolveReceiptFiles(scheme, host, *item)
if len(files) == 0 {
item.ReceiptFileIDs = ""
item.ReceiptURLs = ""
item.ReceiptAmounts = ""
item.ReceiptFlag = 0
return
}
ids := make([]uint64, 0, len(files))
urls := make([]string, 0, len(files))
for _, file := range files {
if id, ok := file["file_id"].(uint64); ok {
ids = append(ids, id)
}
if url, ok := file["url"].(string); ok && url != "" {
urls = append(urls, url)
}
}
item.ReceiptFileIDs = joinReceiptFileIDs(ids)
item.ReceiptURLs = strings.Join(urls, ",")
item.ReceiptFlag = 1
}
func applyReceiptToItem(item *models.BackendReimbursementItem, payload reimbursementItemPayload, scheme, host string) {
oldIDs := parseReceiptFileIDs(item.ReceiptFileIDs)
oldAmounts := parseReceiptAmounts(item.ReceiptAmounts)
if payload.ReceiptFlag == 0 && strings.TrimSpace(payload.ReceiptFileIDs) == "" {
item.ReceiptFileIDs = ""
item.ReceiptURLs = ""
item.ReceiptAmounts = ""
item.ReceiptFlag = 0
return
}
if strings.TrimSpace(payload.ReceiptFileIDs) != "" {
item.ReceiptFileIDs = strings.TrimSpace(payload.ReceiptFileIDs)
syncReceiptFields(item, scheme, host)
newIDs := parseReceiptFileIDs(item.ReceiptFileIDs)
if payload.ReceiptAmounts != nil {
item.ReceiptAmounts = normalizeReceiptAmounts(*payload.ReceiptAmounts, len(newIDs))
} else {
item.ReceiptAmounts = remapReceiptAmounts(oldIDs, oldAmounts, newIDs)
}
return
}
item.ReceiptFlag = payload.ReceiptFlag
item.ReceiptURLs = strings.TrimSpace(payload.ReceiptURLs)
if item.ReceiptURLs == "" && item.ReceiptFlag == 0 {
item.ReceiptFileIDs = ""
item.ReceiptAmounts = ""
}
}
func reimbursementItemDTO(item models.BackendReimbursementItem, scheme, host string) map[string]interface{} {
invoiceName := ""
if item.InvoiceFileID > 0 {
var f models.SystemFile
if err := models.Orm.QueryTable(new(models.SystemFile)).
Filter("id", item.InvoiceFileID).
Filter("delete_time__isnull", true).One(&f, "name"); err == nil {
invoiceName = f.Name
}
}
receiptFiles := resolveReceiptFiles(scheme, host, item)
receiptURLs := normalizeReceiptURLs(scheme, host, item.ReceiptURLs)
receiptFlag := item.ReceiptFlag
if len(receiptFiles) > 0 {
receiptFlag = 1
if receiptURLs == "" {
if url, ok := receiptFiles[0]["url"].(string); ok {
receiptURLs = url
}
}
} else if receiptURLs != "" {
receiptFlag = 1
}
return map[string]interface{}{
"id": item.ID, "reimbursement_id": item.ReimbursementID,
"expense_type": item.ExpenseType, "expense_date": item.ExpenseDate.Format("2006-01-02"),
"amount": item.Amount, "quantity": item.Quantity, "unit_price": item.UnitPrice,
"description": item.Description, "receipt_flag": receiptFlag,
"receipt_file_ids": item.ReceiptFileIDs,
"receipt_urls": receiptURLs, "receipt_amounts": item.ReceiptAmounts,
"receipt_files": receiptFiles,
"invoice_file_id": item.InvoiceFileID,
"invoice_use_amount": item.InvoiceUseAmount,
"invoice_name": invoiceName,
}
}
func reimbursementDTO(row models.BackendReimbursement, items []models.BackendReimbursementItem, scheme, host string) map[string]interface{} {
_, userName := services.OperationLogUser(&row.Tid, row.UserID)
result := map[string]interface{}{
"id": row.ID, "tid": row.Tid, "uid": row.UID, "user_id": row.UserID,
"user_name": userName,
"department_id": row.DepartmentID, "total_amount": row.TotalAmount,
"status": row.Status, "apply_date": row.ApplyDate.Format("2006-01-02"),
"description": row.Description, "current_approver_id": row.CurrentApproverID,
"current_step": row.CurrentStep, "create_time": row.CreateTime, "update_time": row.UpdateTime,
}
list := make([]map[string]interface{}, 0, len(items))
for _, item := range items {
list = append(list, reimbursementItemDTO(item, scheme, host))
}
result["items"] = list
return result
}
// List GET /backend/reimbursements
func (c *BackendReimburseController) List() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
page, _ := c.GetInt("page", 1)
pageSize, _ := c.GetInt("pageSize", 10)
keyword := strings.TrimSpace(c.GetString("keyword"))
status, statusErr := c.GetInt("status")
// 列表 tab:all(不传)/ unpaid(未报销)/ paid(已报销)
reimbursed := strings.TrimSpace(c.GetString("reimbursed"))
data, code, msg := reimburseListData(claims, page, pageSize, keyword, statusErr == nil, status, reimbursed)
if msg != "" {
c.reply(code, msg, nil)
return
}
c.reply(200, "success", data)
}
// reimburseListData 报销单列表共享逻辑:Web 后台与移动端(/api/oa/expense/list)共用。
// reimbursed 供 Web 列表的「全部/未报销/已报销」tab 使用,移动端传空串保持原行为。
// 返回 (数据, HTTP 状态码, 错误信息),错误信息为空串表示成功。
func reimburseListData(claims *jwtutil.Claims, page, pageSize int, keyword string, hasStatus bool, status int, reimbursed string) (map[string]interface{}, int, string) {
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 10
}
qs := models.Orm.QueryTable(new(models.BackendReimbursement)).
Filter("tid", claims.TenantId).Filter("user_id", claims.UserID).Filter("is_deleted", 0)
// 待我审批(status=-1 约定值):审批人视角,切换为「我名下运行中实例的待办单据」
// 查询(yz_approval_task.approver_id=当前用户),不再按创建人 user_id 过滤
mineOnly := hasStatus && status == -1
if mineOnly {
hasStatus = false
taskQs := models.Orm.QueryTable(new(models.ApprovalTask)).
Filter("scope", models.ApprovalScopeTenant).
Filter("tid", claims.TenantId).
Filter("approver_id", claims.UserID).
Filter("status", models.ApprovalTaskPending).
Filter("is_deleted", 0).
Filter("biz_type__in", reimburseFallbackBizTypes)
var tasks []models.ApprovalTask
if _, err := taskQs.All(&tasks, "biz_id"); err != nil {
return nil, 500, "查询审批待办失败"
}
ids := make([]uint64, 0, len(tasks))
for _, t := range tasks {
if t.BizID > 0 {
ids = append(ids, t.BizID)
}
}
if len(ids) == 0 {
return map[string]interface{}{"list": []map[string]interface{}{}, "total": 0}, 200, ""
}
qs = models.Orm.QueryTable(new(models.BackendReimbursement)).
Filter("tid", claims.TenantId).Filter("id__in", ids).Filter("is_deleted", 0)
}
if keyword != "" {
qs = qs.Filter("description__icontains", keyword)
}
// 「全部 / 未报销 / 已报销」:已通过(2) + 已打款(5) 算已报销,
// 草稿/审批中/已驳回/已撤回算未报销,口径与 dashboard 的 paid/unpaid 一致。
// 用 status__in 明确枚举,避免 Exclude 对 NULL 或新增状态的歧义;可与 status 精确筛选叠加。
reimbursedStatuses := []int8{reimbursementStatusApproved, reimbursementStatusPaid}
unreimbursedStatuses := []int8{
reimbursementStatusDraft, reimbursementStatusApproving,
reimbursementStatusRejected, reimbursementStatusWithdrawn,
}
switch reimbursed {
case "paid":
qs = qs.Filter("status__in", reimbursedStatuses)
case "unpaid":
qs = qs.Filter("status__in", unreimbursedStatuses)
}
if hasStatus {
qs = qs.Filter("status", status)
}
total, err := qs.Count()
if err != nil {
return nil, 500, "查询失败"
}
var rows []models.BackendReimbursement
_, err = qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&rows)
if err != nil && err != orm.ErrNoRows {
return nil, 500, "查询失败"
}
if rows == nil {
rows = []models.BackendReimbursement{}
}
list := make([]map[string]interface{}, 0, len(rows))
for _, row := range rows {
// 审批已结束但业务状态未及时回写时,读取即自愈
row.Status = syncReimburseStatusFromInstance(&row)
list = append(list, map[string]interface{}{
"id": row.ID,
"tid": row.Tid,
"uid": row.UID,
"user_id": row.UserID,
"department_id": row.DepartmentID,
"total_amount": row.TotalAmount,
"status": row.Status,
"apply_date": row.ApplyDate.Format("2006-01-02"),
"description": row.Description,
"current_approver_id": row.CurrentApproverID,
"current_step": row.CurrentStep,
"create_time": row.CreateTime,
"update_time": row.UpdateTime,
})
}
return map[string]interface{}{"list": list, "total": total}, 200, ""
}
// Detail GET /backend/reimbursements/:id
func (c *BackendReimburseController) Detail() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
id, ok := c.getID()
if !ok {
c.reply(400, "无效ID", nil)
return
}
scheme, host := services.PublicRequestBaseURL(&c.Controller)
data, code, msg := reimburseDetailData(claims, id, scheme, host)
if msg != "" {
c.reply(code, msg, nil)
return
}
c.reply(200, "success", data)
}
// reimburseDetailData 报销单详情共享逻辑(主单 + 明细 + 发票文件)。
func reimburseDetailData(claims *jwtutil.Claims, id uint64, scheme, host string) (map[string]interface{}, int, string) {
row, err := findOwnedReimbursement(claims, id)
if err != nil {
return nil, 404, "报销单不存在"
}
var items []models.BackendReimbursementItem
// 只按 reimbursement_id 查明细:主单已按 tid + 申请人校验过。
// 历史单据存在明细 tid 与主单不一致的情况,再按 tid 过滤会导致明细「消失」。
_, err = models.Orm.QueryTable(new(models.BackendReimbursementItem)).
Filter("reimbursement_id", id).Filter("is_deleted", 0).
OrderBy("id").All(&items)
if err != nil && err != orm.ErrNoRows {
return nil, 500, "查询明细失败: " + err.Error()
}
// 审批已结束但业务状态未及时回写时,读取即自愈
row.Status = syncReimburseStatusFromInstance(row)
return reimbursementDTO(*row, items, scheme, host), 200, ""
}
// syncReimburseStatusFromInstance 报销单状态自愈:单据处于「审批中」时,
// 按审批引擎中对应实例(biz_type=reimburse)的最终态回写业务状态。
// 用于兜底引擎回写异常/历史数据,列表与详情读取时调用,无变化时原样返回。
func syncReimburseStatusFromInstance(row *models.BackendReimbursement) int8 {
if row == nil {
return 0
}
if row.Status != 1 {
return row.Status
}
inst, ok := findReimburseInstance(row.Tid, row.ID)
if !ok || inst.Status == models.ApprovalInstanceRunning {
return row.Status
}
approvalEngine.SyncBizResult(inst.ID, inst.Status)
switch inst.Status {
case models.ApprovalInstanceApproved:
return 2
case models.ApprovalInstanceRejected:
return 3
case models.ApprovalInstanceWithdrawn:
return 4
}
return row.Status
}
// findReimburseInstance 查报销单对应的审批实例。
// 兼容两种 biz_type 写法(reimburse / 早期的 oa),并按 tid、biz_tid 依次匹配,
// 这样租户侧与平台侧产生的实例都能找到。
func findReimburseInstance(tid, bizID uint64) (*models.ApprovalInstance, bool) {
if tid == 0 || bizID == 0 {
return nil, false
}
for _, key := range []string{"tid", "biz_tid"} {
var inst models.ApprovalInstance
if err := models.Orm.QueryTable(new(models.ApprovalInstance)).
Filter(key, tid).
Filter("biz_type__in", models.ApprovalBizReimburse, approvalEngine.BizTypeLegacyOA).
Filter("biz_id", bizID).
Filter("is_deleted", 0).
OrderBy("-id").One(&inst); err == nil {
return &inst, true
}
}
return nil, false
}
// Create POST /backend/reimbursements
func (c *BackendReimburseController) Create() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
payload, err := c.parsePayload()
if err != nil {
c.reply(400, "参数错误", nil)
return
}
id, code, msg := reimburseCreateCore(claims, payload)
if msg != "" {
c.reply(code, msg, nil)
return
}
c.reply(200, "success", map[string]interface{}{"id": id})
}
// reimburseCreateCore 创建报销单(草稿)共享逻辑。
//
// 创建只落草稿:不接受客户端传入的状态,也不启动审批流程。
// 提交审批必须由用户显式调用 Submit(Web 端为列表操作栏的「提交审核」),
// 在此之前草稿可反复编辑,满足周期性报销多次修改的需求。
func reimburseCreateCore(claims *jwtutil.Claims, payload reimbursementPayload) (uint64, int, string) {
applyDate, err := parseReimburseDate(payload.ApplyDate)
if err != nil {
return 0, 400, "申请日期格式错误"
}
uid := uint64(claims.UserID)
row := &models.BackendReimbursement{
Tid: uint64(claims.TenantId), UID: &uid, UserID: uid, DepartmentID: payload.DepartmentID,
TotalAmount: 0, Status: reimbursementStatusDraft, ApplyDate: applyDate,
CurrentStep: 0, CurrentApproverID: 0,
Description: nullableReimburseString(payload.Description),
}
if err := saveReimbursement(row); err != nil {
return 0, 500, "创建报销单失败"
}
return row.ID, 200, ""
}
// Update POST /backend/reimbursements/:id
func (c *BackendReimburseController) Update() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
id, ok := c.getID()
if !ok {
c.reply(400, "无效ID", nil)
return
}
payload, err := c.parsePayload()
if err != nil {
c.reply(400, "参数错误", nil)
return
}
code, msg := reimburseUpdateCore(claims, id, payload)
if msg != "" {
c.reply(code, msg, nil)
return
}
c.reply(200, "success", map[string]interface{}{"id": id})
}
// reimburseUpdateCore 编辑报销单(草稿/已驳回/已撤回)共享逻辑。
func reimburseUpdateCore(claims *jwtutil.Claims, id uint64, payload reimbursementPayload) (int, string) {
row, err := findOwnedReimbursement(claims, id)
if err != nil {
return 404, "报销单不存在"
}
if !canEditReimbursement(row.Status) {
return 400, "当前状态不可编辑,请先撤回"
}
applyDate, err := parseReimburseDate(payload.ApplyDate)
if err != nil {
return 400, "申请日期格式错误"
}
row.ApplyDate, row.Description = applyDate, nullableReimburseString(payload.Description)
if err := saveReimbursement(row); err != nil {
return 500, "更新报销单失败"
}
return 200, ""
}
// Delete DELETE /backend/reimbursements/:id
func (c *BackendReimburseController) Delete() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
id, ok := c.getID()
if !ok {
c.reply(400, "无效ID", nil)
return
}
code, msg := reimburseDeleteCore(claims, id)
if msg != "" {
c.reply(code, msg, nil)
return
}
c.reply(200, "success", nil)
}
// reimburseDeleteCore 删除报销单(仅草稿,连明细一起软删)共享逻辑。
func reimburseDeleteCore(claims *jwtutil.Claims, id uint64) (int, string) {
row, err := findOwnedReimbursement(claims, id)
if err != nil {
return 404, "报销单不存在"
}
if row.Status != reimbursementStatusDraft {
return 400, "只有草稿可以删除"
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.BackendReimbursement)).Filter("id", id).
Update(orm.Params{"is_deleted": 1, "delete_time": now, "update_time": now})
if err != nil {
return 500, "删除失败"
}
_, _ = models.Orm.QueryTable(new(models.BackendReimbursementItem)).
Filter("reimbursement_id", id).Update(orm.Params{"is_deleted": 1, "delete_time": now})
return 200, ""
}
func (c *BackendReimburseController) parseItemPayload() (reimbursementItemPayload, error) {
var payload reimbursementItemPayload
raw, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil {
return payload, err
}
err = json.Unmarshal(raw, &payload)
return payload, err
}
func (c *BackendReimburseController) getItemID() (uint64, bool) {
id, err := strconv.ParseUint(c.Ctx.Input.Param(":item_id"), 10, 64)
return id, err == nil && id > 0
}
// findOwnedReimbursementItem 查询本人报销单下的费用明细(包级共享,移动端复用)。
// 主单归属已由 ensureEditableReimbursement 按 tid + 申请人校验,这里只按
// id + reimbursement_id 定位,避免历史明细 tid/uid 不一致时无法编辑。
func findOwnedReimbursementItem(_ *jwtutil.Claims, id, reimbursementID uint64) (*models.BackendReimbursementItem, error) {
item := &models.BackendReimbursementItem{}
err := models.Orm.QueryTable(new(models.BackendReimbursementItem)).
Filter("id", id).
Filter("reimbursement_id", reimbursementID).
Filter("is_deleted", 0).
One(item)
return item, err
}
func validateReimbursementItem(item reimbursementItemPayload) (time.Time, error) {
if strings.TrimSpace(item.ExpenseType) == "" {
return time.Time{}, fmt.Errorf("费用类型不能为空")
}
date, err := parseReimburseDate(item.ExpenseDate)
if err != nil {
return time.Time{}, fmt.Errorf("费用日期格式错误")
}
if item.Amount <= 0 {
return time.Time{}, fmt.Errorf("费用金额必须大于0")
}
return date, nil
}
// ---------------------------------------------------------------- 发票挂靠
// invoicePoolEntry 发票池中的一张票(按附件文件 ID 聚合)。
type invoicePoolEntry struct {
FileID uint64
Name string
URL string
ExpenseDate string
Amount float64 // 票面金额(receipt_amounts)
UsedAmount float64 // 已被各明细占用的金额
SourceItemID uint64
SourceReimbursementID uint64
}
// collectInvoicePool 聚合「当前报销单」内的发票及余额。
//
// 边界:只能挂靠本报销单里上传过的发票,不跨单据、不跨用户。
// 口径:
// - 票面金额取「上传该票的明细行」receipt_amounts 对应值(OCR 或人工填写);
// - 上传发票的明细按「明细金额 / 该行发票张数」自动占用额度,保证一条明细
// 上传多张票时不会被重复占用;
// - 本单其它明细通过 invoice_file_id + invoice_use_amount 显式挂靠,累加占用;
// - 余额 = 票面 - 占用。明细删除(软删)后占用自动释放,无需额外回滚。
//
// excludeItemID 用于编辑某条明细时排除它自身的占用,得到「本单其它明细用完后
// 还剩多少可挂靠」。
func collectInvoicePool(claims *jwtutil.Claims, reimbursementID, excludeItemID uint64) ([]invoicePoolEntry, error) {
if reimbursementID == 0 {
return []invoicePoolEntry{}, nil
}
// 归属校验:只允许访问本人的报销单(tid + user_id + 未删除)
if _, err := findOwnedReimbursement(claims, reimbursementID); err != nil {
return nil, fmt.Errorf("报销单不存在")
}
var items []models.BackendReimbursementItem
_, err := models.Orm.QueryTable(new(models.BackendReimbursementItem)).
Filter("reimbursement_id", reimbursementID).
Filter("is_deleted", 0).
All(&items)
if err != nil && err != orm.ErrNoRows {
return nil, err
}
pool := map[uint64]*invoicePoolEntry{}
for i := range items {
row := &items[i]
if row.ID == excludeItemID {
continue
}
ids := parseReceiptFileIDs(row.ReceiptFileIDs)
if len(ids) == 0 {
continue
}
amountParts := parseReceiptAmounts(row.ReceiptAmounts)
urlParts := strings.Split(row.ReceiptURLs, ",")
share := row.Amount / float64(len(ids))
for idx, id := range ids {
entry := pool[id]
if entry == nil {
entry = &invoicePoolEntry{
FileID: id, SourceItemID: row.ID,
SourceReimbursementID: row.ReimbursementID,
ExpenseDate: row.ExpenseDate.Format("2006-01-02"),
}
if idx < len(urlParts) {
entry.URL = strings.TrimSpace(urlParts[idx])
}
if idx < len(amountParts) && amountParts[idx] != "" {
entry.Amount, _ = strconv.ParseFloat(amountParts[idx], 64)
}
pool[id] = entry
}
entry.UsedAmount += share
}
}
for i := range items {
row := &items[i]
if row.ID == excludeItemID || row.InvoiceFileID == 0 || row.InvoiceUseAmount <= 0 {
continue
}
// 自己上传的票已按明细金额算过宿主占用,不重复累计
selfUploaded := false
for _, id := range parseReceiptFileIDs(row.ReceiptFileIDs) {
if id == row.InvoiceFileID {
selfUploaded = true
break
}
}
if selfUploaded {
continue
}
if entry := pool[row.InvoiceFileID]; entry != nil {
entry.UsedAmount += row.InvoiceUseAmount
}
}
// 一次性补齐文件名,避免逐票查询
ids := make([]uint64, 0, len(pool))
for id := range pool {
ids = append(ids, id)
}
if len(ids) > 0 {
var files []models.SystemFile
_, _ = models.Orm.QueryTable(new(models.SystemFile)).
Filter("id__in", ids).Filter("delete_time__isnull", true).All(&files, "id", "name")
names := map[uint64]string{}
for i := range files {
names[files[i].ID] = files[i].Name
}
for id, entry := range pool {
entry.Name = names[id]
if entry.Name == "" && entry.URL != "" {
entry.Name = path.Base(strings.Split(entry.URL, "?")[0])
}
}
}
result := make([]invoicePoolEntry, 0, len(pool))
for _, entry := range pool {
result = append(result, *entry)
}
sort.Slice(result, func(i, j int) bool {
if result[i].ExpenseDate == result[j].ExpenseDate {
return result[i].FileID > result[j].FileID
}
return result[i].ExpenseDate > result[j].ExpenseDate
})
return result, nil
}
// validateInvoiceLink 校验挂靠是否合法:只能挂靠当前报销单里上传的发票,
// 且票面金额已维护、余额充足、本次使用额不超过明细金额。
func validateInvoiceLink(claims *jwtutil.Claims, payload reimbursementItemPayload, reimbursementID, excludeItemID uint64) (int, string) {
// 未带挂靠字段(上传发票 / 保存票面金额等)表示不改挂靠关系,直接放行
if payload.InvoiceFileID == nil {
return 200, ""
}
fileID := *payload.InvoiceFileID
if fileID == 0 {
return 200, ""
}
useAmount := float64(0)
if payload.InvoiceUseAmount != nil {
useAmount = *payload.InvoiceUseAmount
}
if useAmount <= 0 {
return 400, "挂靠金额必须大于0"
}
if useAmount > payload.Amount+0.005 {
return 400, "挂靠金额不能超过本条费用明细金额"
}
// 本行已经上传这张票时无需再挂靠:宿主明细本身已按明细金额占用额度
for _, id := range parseReceiptFileIDs(payload.ReceiptFileIDs) {
if id == fileID {
return 400, "本条明细已上传该发票,无需重复挂靠"
}
}
// 发票池只取本报销单的明细,因此天然挡住跨单据 / 跨用户挂靠
pool, err := collectInvoicePool(claims, reimbursementID, excludeItemID)
if err != nil {
return 400, "报销单不存在或无法读取发票信息"
}
for _, entry := range pool {
if entry.FileID != fileID {
continue
}
if entry.Amount <= 0 {
return 400, "所选发票未维护票面金额,请先在「核对发票金额」中填写"
}
remain := entry.Amount - entry.UsedAmount
if useAmount > remain+0.005 {
return 400, fmt.Sprintf("发票余额不足,当前剩余 ¥%.2f", remain)
}
return 200, ""
}
return 400, "该发票不属于当前报销单,或已无余额;请取消挂靠后选择本单上传的发票"
}
// AvailableInvoices GET /backend/reimbursements/invoices/available?reimbursement_id=
// 返回「当前报销单」内仍有余额、可供挂靠的发票;其它报销单的发票一律不可见。
func (c *BackendReimburseController) AvailableInvoices() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
reimbursementID, err := strconv.ParseUint(strings.TrimSpace(c.GetString("reimbursement_id")), 10, 64)
if err != nil || reimbursementID == 0 {
c.reply(400, "缺少报销单ID", nil)
return
}
excludeID, _ := strconv.ParseUint(strings.TrimSpace(c.GetString("exclude_item_id")), 10, 64)
keyword := strings.ToLower(strings.TrimSpace(c.GetString("keyword")))
pool, err := collectInvoicePool(claims, reimbursementID, excludeID)
if err != nil {
c.reply(400, "报销单不存在或无法读取发票信息", nil)
return
}
scheme, host := services.PublicRequestBaseURL(&c.Controller)
list := make([]map[string]interface{}, 0, len(pool))
for _, entry := range pool {
remain := entry.Amount - entry.UsedAmount
if entry.Amount <= 0 || remain <= 0.005 {
continue
}
if keyword != "" && !strings.Contains(strings.ToLower(entry.Name), keyword) {
continue
}
list = append(list, map[string]interface{}{
"file_id": entry.FileID,
"name": entry.Name,
"url": services.ResolvePublicURL(scheme, host, entry.URL),
"amount": entry.Amount,
"used_amount": entry.UsedAmount,
"remain_amount": remain,
"source_item_id": entry.SourceItemID,
"source_reimbursement_id": entry.SourceReimbursementID,
"expense_date": entry.ExpenseDate,
})
}
c.reply(200, "success", map[string]interface{}{"list": list})
}
// ---------------------------------------------------------------- 发票挂靠结束
// canEditReimbursement 可编辑状态:草稿、已驳回、已撤回。
// 审批中需先撤回;已通过/已打款不可编辑,保证审批结果与打款金额不被改动。
func canEditReimbursement(status int8) bool {
switch status {
case reimbursementStatusDraft, reimbursementStatusRejected, reimbursementStatusWithdrawn:
return true
}
return false
}
// resolveReimburseUserName 按 identity_id 解析姓名:
// 认证中心企业绑定表 → 身份表 → 老租户用户表。历史令牌/导入数据可能缺映射,逐级兜底。
func resolveReimburseUserName(tid, userID uint64) string {
if userID == 0 {
return ""
}
if tid != 0 {
var bind models.AuthTenantUser
if err := models.Orm.QueryTable(new(models.AuthTenantUser)).
Filter("tid", tid).
Filter("identity_id", userID).
Filter("delete_time__isnull", true).
One(&bind); err == nil {
if bind.Name != nil && strings.TrimSpace(*bind.Name) != "" {
return strings.TrimSpace(*bind.Name)
}
if bind.Account != nil && strings.TrimSpace(*bind.Account) != "" {
return strings.TrimSpace(*bind.Account)
}
}
}
var ident models.AuthIdentity
if err := models.Orm.QueryTable(new(models.AuthIdentity)).
Filter("id", userID).
One(&ident); err == nil {
if ident.Nickname != nil && strings.TrimSpace(*ident.Nickname) != "" {
return strings.TrimSpace(*ident.Nickname)
}
if ident.Account != nil && strings.TrimSpace(*ident.Account) != "" {
return strings.TrimSpace(*ident.Account)
}
if ident.Mobile != nil && strings.TrimSpace(*ident.Mobile) != "" {
return strings.TrimSpace(*ident.Mobile)
}
}
account, name := services.OperationLogUser(&tid, userID)
if strings.TrimSpace(name) != "" {
return strings.TrimSpace(name)
}
return strings.TrimSpace(account)
}
// reimburseActorName 审批时间线的操作人名称:
// 优先令牌中的 username,缺失时回落到认证中心/租户用户表。
func reimburseActorName(tid uint64, claims *jwtutil.Claims) string {
if claims == nil {
return "系统"
}
if name := strings.TrimSpace(claims.Username); name != "" {
return name
}
if name := resolveReimburseUserName(tid, uint64(claims.UserID)); name != "" {
return name
}
return "系统"
}
// ensureEditableReimbursement 校验报销单存在且处于可编辑状态(包级共享,移动端复用)。
// 返回 (报销单, HTTP 状态码, 错误信息),错误信息为空串表示通过。
func ensureEditableReimbursement(claims *jwtutil.Claims, id uint64) (*models.BackendReimbursement, int, string) {
row, err := findOwnedReimbursement(claims, id)
if err != nil {
return nil, 404, "报销单不存在"
}
if !canEditReimbursement(row.Status) {
return nil, 400, "当前状态不可编辑费用明细,请先撤回"
}
return row, 200, ""
}
func recalculateReimbursementTotal(id uint64) error {
var items []models.BackendReimbursementItem
_, err := models.Orm.QueryTable(new(models.BackendReimbursementItem)).
Filter("reimbursement_id", id).Filter("is_deleted", 0).All(&items)
if err != nil && err != orm.ErrNoRows {
return err
}
var total float64
for _, item := range items {
total += item.Amount
}
_, err = models.Orm.QueryTable(new(models.BackendReimbursement)).
Filter("id", id).Update(orm.Params{
"total_amount": total,
"update_time": time.Now(),
})
return err
}
// AddItem POST /backend/reimbursements/:id/items
func (c *BackendReimburseController) AddItem() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
reimbursementID, ok := c.getID()
if !ok {
c.reply(400, "无效报销单ID", nil)
return
}
payload, err := c.parseItemPayload()
if err != nil {
c.reply(400, "参数错误", nil)
return
}
scheme, host := services.PublicRequestBaseURL(&c.Controller)
item, code, msg := reimburseAddItemCore(claims, reimbursementID, payload, scheme, host)
if msg != "" {
c.reply(code, msg, nil)
return
}
c.reply(200, "success", item)
}
// reimburseAddItemCore 新增费用明细共享逻辑(仅草稿;自动重算报销总额)。
func reimburseAddItemCore(claims *jwtutil.Claims, reimbursementID uint64, payload reimbursementItemPayload, scheme, host string) (map[string]interface{}, int, string) {
if _, _, msg := ensureEditableReimbursement(claims, reimbursementID); msg != "" {
code := 400
if msg == "报销单不存在" {
code = 404
}
return nil, code, msg
}
date, err := validateReimbursementItem(payload)
if err != nil {
return nil, 400, err.Error()
}
if code, msg := validateInvoiceLink(claims, payload, reimbursementID, 0); msg != "" {
return nil, code, msg
}
uid := uint64(claims.UserID)
quantity := payload.Quantity
if quantity <= 0 {
quantity = 1
}
fileID := uint64(0)
useAmount := float64(0)
if payload.InvoiceFileID != nil {
fileID = *payload.InvoiceFileID
}
if payload.InvoiceUseAmount != nil {
useAmount = *payload.InvoiceUseAmount
}
if fileID == 0 {
useAmount = 0
}
item := &models.BackendReimbursementItem{
Tid: uint64(claims.TenantId), UID: &uid, ReimbursementID: reimbursementID,
ExpenseType: strings.TrimSpace(payload.ExpenseType), ExpenseDate: date,
Amount: payload.Amount, Quantity: quantity, UnitPrice: payload.UnitPrice,
Description: strings.TrimSpace(payload.Description),
InvoiceFileID: fileID, InvoiceUseAmount: useAmount,
}
applyReceiptToItem(item, payload, scheme, host)
if _, err := models.Orm.Insert(item); err != nil {
return nil, 500, "新增费用明细失败"
}
if err := recalculateReimbursementTotal(reimbursementID); err != nil {
return nil, 500, "更新报销总额失败"
}
return reimbursementItemDTO(*item, scheme, host), 200, ""
}
// UpdateItem POST /backend/reimbursements/:id/items/:item_id
func (c *BackendReimburseController) UpdateItem() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
reimbursementID, ok := c.getID()
if !ok {
c.reply(400, "无效报销单ID", nil)
return
}
itemID, ok := c.getItemID()
if !ok {
c.reply(400, "无效费用明细ID", nil)
return
}
payload, err := c.parseItemPayload()
if err != nil {
c.reply(400, "参数错误", nil)
return
}
scheme, host := services.PublicRequestBaseURL(&c.Controller)
item, code, msg := reimburseUpdateItemCore(claims, reimbursementID, itemID, payload, scheme, host)
if msg != "" {
c.reply(code, msg, nil)
return
}
c.reply(200, "success", item)
}
// reimburseUpdateItemCore 更新费用明细共享逻辑(仅草稿;自动重算报销总额)。
func reimburseUpdateItemCore(claims *jwtutil.Claims, reimbursementID, itemID uint64, payload reimbursementItemPayload, scheme, host string) (map[string]interface{}, int, string) {
if _, _, msg := ensureEditableReimbursement(claims, reimbursementID); msg != "" {
code := 400
if msg == "报销单不存在" {
code = 404
}
return nil, code, msg
}
item, err := findOwnedReimbursementItem(claims, itemID, reimbursementID)
if err != nil {
return nil, 404, "费用明细不存在"
}
date, err := validateReimbursementItem(payload)
if err != nil {
return nil, 400, err.Error()
}
if code, msg := validateInvoiceLink(claims, payload, reimbursementID, itemID); msg != "" {
return nil, code, msg
}
quantity := payload.Quantity
if quantity <= 0 {
quantity = 1
}
// 只有显式携带挂靠字段才改动挂靠关系;上传发票 / 保存票面金额等请求
// 不带这两个字段,必须保留原有挂靠,避免被清零。
if payload.InvoiceFileID != nil {
fileID := *payload.InvoiceFileID
useAmount := float64(0)
if payload.InvoiceUseAmount != nil {
useAmount = *payload.InvoiceUseAmount
}
if fileID == 0 {
useAmount = 0
}
item.InvoiceFileID = fileID
item.InvoiceUseAmount = useAmount
}
item.ExpenseType = strings.TrimSpace(payload.ExpenseType)
item.ExpenseDate = date
item.Amount = payload.Amount
item.Quantity = quantity
item.UnitPrice = payload.UnitPrice
item.Description = strings.TrimSpace(payload.Description)
applyReceiptToItem(item, payload, scheme, host)
if _, err := models.Orm.Update(item); err != nil {
return nil, 500, "更新费用明细失败"
}
if err := recalculateReimbursementTotal(reimbursementID); err != nil {
return nil, 500, "更新报销总额失败"
}
return reimbursementItemDTO(*item, scheme, host), 200, ""
}
// DeleteItem DELETE /backend/reimbursements/:id/items/:item_id
func (c *BackendReimburseController) DeleteItem() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
reimbursementID, ok := c.getID()
if !ok {
c.reply(400, "无效报销单ID", nil)
return
}
itemID, ok := c.getItemID()
if !ok {
c.reply(400, "无效费用明细ID", nil)
return
}
code, msg := reimburseDeleteItemCore(claims, reimbursementID, itemID)
if msg != "" {
c.reply(code, msg, nil)
return
}
c.reply(200, "success", nil)
}
// reimburseDeleteItemCore 删除费用明细共享逻辑(仅草稿;自动重算报销总额)。
func reimburseDeleteItemCore(claims *jwtutil.Claims, reimbursementID, itemID uint64) (int, string) {
if _, _, msg := ensureEditableReimbursement(claims, reimbursementID); msg != "" {
if msg == "报销单不存在" {
return 404, msg
}
return 400, msg
}
item, err := findOwnedReimbursementItem(claims, itemID, reimbursementID)
if err != nil {
return 404, "费用明细不存在"
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.BackendReimbursementItem)).
Filter("id", item.ID).Update(orm.Params{"is_deleted": 1, "delete_time": now, "update_time": now})
if err != nil {
return 500, "删除费用明细失败"
}
if err := recalculateReimbursementTotal(reimbursementID); err != nil {
return 500, "更新报销总额失败"
}
return 200, ""
}
// Submit POST /backend/reimbursements/:id/submit
func (c *BackendReimburseController) Submit() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
id, ok := c.getID()
if !ok {
c.reply(400, "无效ID", nil)
return
}
code, msg := reimburseSubmitCore(claims, id, c.readReimburseActPayload())
if msg != "" {
c.reply(code, msg, nil)
return
}
c.reply(200, "success", nil)
}
// Withdraw POST /backend/reimbursements/:id/withdraw
//
// 报销人撤回审批中的单据:撤掉运行中的审批实例,并把单据退回草稿,
// 撤回后可在详情继续编辑费用条目,确认无误后再次提交审核。
func (c *BackendReimburseController) Withdraw() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
id, ok := c.getID()
if !ok {
c.reply(400, "无效ID", nil)
return
}
code, msg := reimburseWithdrawCore(claims, id, c.readReimburseActPayload())
if msg != "" {
c.reply(code, msg, nil)
return
}
c.reply(200, "success", nil)
}
// reimburseWithdrawCore 撤回审批中的报销单并退回草稿。
//
// 与「审批中心」的发起人撤回一致:运行中的实例置为已撤回、待办任务置为已跳过;
// 区别是报销单本身退回草稿(0)而不是已撤回(4),这样用户能继续编辑条目并重新提交。
func reimburseWithdrawCore(claims *jwtutil.Claims, id uint64, comment string) (int, string) {
row, err := findOwnedReimbursement(claims, id)
if err != nil {
return 404, "报销单不存在"
}
if row.Status != reimbursementStatusApproving {
return 400, "只有审批中的报销单可以撤回"
}
// 历史单据可能没有审批实例:没有实例时直接退回草稿,保证仍可编辑
inst, ok := findReimburseInstance(row.Tid, row.ID)
if ok {
if inst.Status != models.ApprovalInstanceRunning {
return 400, "审批已结束,无法撤回"
}
now := time.Now()
if _, err = models.Orm.QueryTable(new(models.ApprovalInstance)).
Filter("id", inst.ID).
Update(orm.Params{
"status": models.ApprovalInstanceWithdrawn,
"finish_time": now,
"update_time": now,
}); err != nil {
return 500, "撤回审批失败"
}
_, _ = models.Orm.QueryTable(new(models.ApprovalTask)).
Filter("instance_id", inst.ID).
Filter("status", models.ApprovalTaskPending).
Update(orm.Params{"status": models.ApprovalTaskSkipped, "update_time": now})
}
now := time.Now()
if _, err = models.Orm.QueryTable(new(models.BackendReimbursement)).
Filter("id", id).Filter("tid", claims.TenantId).
Update(orm.Params{
"status": reimbursementStatusDraft, "current_step": 0,
"current_approver_id": 0, "update_time": now,
}); err != nil {
return 500, "撤回失败"
}
// 时间线补一条撤回记录(不调用引擎 SyncBizResult,避免把单据同步成「已撤回」)
_, _ = models.Orm.Insert(&models.BackendApprovalRecord{
Tid: uint64(claims.TenantId), UID: uint64Ptr(uint64(claims.UserID)),
ReimbursementID: id, ApproverID: uint64(claims.UserID),
ApproverName: reimburseActorName(uint64(claims.TenantId), claims),
Action: models.ApprovalActionWithdraw, Comment: nullableReimburseString(comment),
Step: row.CurrentStep, PrevStep: row.CurrentStep,
})
return 200, ""
}
// reimburseActPayload 审批操作请求体。
type reimburseActPayload struct {
Comment string `json:"comment"`
}
// readReimburseComment 读取审批/撤回意见(可空),Web 与移动端共用。
func readReimburseComment(ctrl *beego.Controller) string {
var payload reimburseActPayload
raw, err := io.ReadAll(ctrl.Ctx.Request.Body)
if err == nil && len(raw) > 0 {
_ = json.Unmarshal(raw, &payload)
}
return strings.TrimSpace(payload.Comment)
}
// readReimburseActPayload 读取审批意见(可空)。
func (c *BackendReimburseController) readReimburseActPayload() string {
return readReimburseComment(&c.Controller)
}
// Approve POST /backend/reimbursements/approve/:id 通过当前审批节点(body: {comment})。
func (c *BackendReimburseController) Approve() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
id, ok := c.getID()
if !ok {
c.reply(400, "无效ID", nil)
return
}
code, msg, hint := reimburseActCore(claims, id, true, c.readReimburseActPayload())
if msg != "" {
c.reply(code, msg, nil)
return
}
c.reply(200, hint, nil)
}
// Reject POST /backend/reimbursements/reject/:id 驳回报销单(body: {comment})。
func (c *BackendReimburseController) Reject() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
id, ok := c.getID()
if !ok {
c.reply(400, "无效ID", nil)
return
}
code, msg, hint := reimburseActCore(claims, id, false, c.readReimburseActPayload())
if msg != "" {
c.reply(code, msg, nil)
return
}
c.reply(200, hint, nil)
}
// MyApproveTask GET /backend/reimbursements/my-approve-task/:id
// 查当前用户在该单据上的待审批任务:返回 {task_id, node_name},无待办返回 data=null。
func (c *BackendReimburseController) MyApproveTask() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
id, ok := c.getID()
if !ok {
c.reply(400, "无效ID", nil)
return
}
task, err := reimburseMyTaskCore(claims, id)
if err != nil {
c.reply(500, "查询审批任务失败", nil)
return
}
if task == nil {
c.reply(200, "success", nil)
return
}
c.reply(200, "success", map[string]interface{}{
"task_id": task.ID,
"node_name": task.NodeName,
})
}
// reimburseFallbackBizTypes 报销匹配审批流的单据类型回退顺序:
// 先按报销专有流(biz_type=reimburse),未配置时回退到 OA 分类默认流
// (FlowSave 未显式指定单据类型时 biz_type=module_code,即 oa)。
var reimburseFallbackBizTypes = []string{models.ApprovalBizReimburse, "oa"}
// reimburseSubmitCore 提交审批共享逻辑:仅草稿可提交,需租户已配置启用审批流。
//
// 提交进入统一审批引擎(biz_type=reimburse、biz_id=报销单ID),引擎负责节点流转;
// 审批最终结果由 services/approval.SyncBizResult 回写到报销单状态,保证
// 「审批中心」与「报销管理」看到的状态一致。
// 提交后报销单置为审批中(1),并写入 action=1 的审批记录供业务详情时间线展示。
func reimburseSubmitCore(claims *jwtutil.Claims, id uint64, comment string) (int, string) {
row, err := findOwnedReimbursement(claims, id)
if err != nil {
return 404, "报销单不存在"
}
if !canEditReimbursement(row.Status) {
return 400, "当前状态不可提交"
}
tid := uint64(claims.TenantId)
flowID := findReimburseFlowID(tid)
if flowID == 0 {
return 400, "尚未配置审批流程"
}
actorName := reimburseActorName(tid, claims)
instID, err := approvalEngine.Submit(approvalEngine.SubmitInput{
Scope: models.ApprovalScopeTenant,
Tid: tid,
FlowID: flowID,
BizType: models.ApprovalBizReimburse,
BizID: id,
BizTitle: reimburseBizTitle(row),
SubmitUID: uint64(claims.UserID),
SubmitName: actorName,
DeptID: reimburseSubmitDeptID(tid, uint64(claims.UserID)),
FormData: map[string]interface{}{
"id": id,
"total_amount": row.TotalAmount,
"apply_date": row.ApplyDate.Format("2006-01-02"),
"description": reimburseDescription(row),
"department_id": row.DepartmentID,
},
})
if err != nil {
return 400, err.Error()
}
// 流程未配置审批节点时引擎会直接通过,此时报销单同步为已通过(2)
status := reimbursementStatusApproving
var inst models.ApprovalInstance
if e := models.Orm.QueryTable(new(models.ApprovalInstance)).Filter("id", instID).One(&inst); e == nil {
switch inst.Status {
case models.ApprovalInstanceApproved:
status = 2
case models.ApprovalInstanceRejected:
status = 3
}
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.BackendReimbursement)).Filter("id", id).Update(orm.Params{
"status": status, "current_step": 0, "current_approver_id": 0, "update_time": now,
})
if err != nil {
return 500, "提交失败"
}
_, err = models.Orm.Insert(&models.BackendApprovalRecord{
Tid: tid, UID: uint64Ptr(uint64(claims.UserID)), ReimbursementID: id,
ApproverID: uint64(claims.UserID), ApproverName: actorName,
Action: models.ApprovalActionSubmit, Comment: nullableReimburseString(comment),
Step: 0, PrevStep: 0,
})
if err != nil {
return 500, "提交审批记录失败"
}
return 200, ""
}
// findReimburseFlowID 查找报销单可用的启用审批流:
// 优先精确匹配 biz_type=reimburse;若租户未区分单据类型(只按模块 oa 配置),回退到模块流程。
func findReimburseFlowID(tid uint64) uint64 {
var flow models.TenantApprovalFlow
if err := models.Orm.QueryTable(new(models.TenantApprovalFlow)).
Filter("tid", tid).Filter("biz_type", models.ApprovalBizReimburse).
Filter("status", 1).Filter("is_deleted", 0).
OrderBy("-is_default", "-id").One(&flow); err == nil {
return flow.ID
}
if err := models.Orm.QueryTable(new(models.TenantApprovalFlow)).
Filter("tid", tid).Filter("module_code", "oa").
Filter("status", 1).Filter("is_deleted", 0).
Filter("biz_type__in", "", "oa").
OrderBy("-is_default", "-id").One(&flow); err == nil {
return flow.ID
}
return 0
}
// reimburseSubmitDeptID 取发起人所属组织ID,供审批引擎按「部门主管」规则解析审批人。
func reimburseSubmitDeptID(tid, uid uint64) uint64 {
var tu models.SystemTenantUser
if err := models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("uid", uid).Filter("tid", tid).
Filter("delete_time__isnull", true).One(&tu); err != nil {
return 0
}
return tu.OrgID
}
// reimburseBizTitle 审批标题:优先报销说明,其次带单据号。
func reimburseBizTitle(row *models.BackendReimbursement) string {
if desc := reimburseDescription(row); desc != "" {
return desc
}
return fmt.Sprintf("报销单 #%d", row.ID)
}
func reimburseDescription(row *models.BackendReimbursement) string {
if row == nil || row.Description == nil {
return ""
}
return strings.TrimSpace(*row.Description)
}
type reimbursementStatusPayload struct {
Status int8 `json:"status"`
}
// 报销单状态(0草稿 1审批中 2已通过 3已驳回 4已撤回 5已打款)
const (
// reimbursementStatusDraft 草稿:新建后的唯一初始状态。
// 草稿可反复编辑/增删明细,只有用户在列表点「提交审核」才会进入审批流。
reimbursementStatusDraft int8 = 0
reimbursementStatusApproving int8 = 1 // 审批中
reimbursementStatusApproved int8 = 2 // 已通过
reimbursementStatusRejected int8 = 3 // 已驳回
reimbursementStatusWithdrawn int8 = 4 // 已撤回
reimbursementStatusPaid int8 = 5 // 已打款
)
// isReimbursedStatus 判断报销单是否属于「已报销」。
// 业务口径:审批已通过(2) 与已打款(5) 均算已报销;
// 草稿(0) / 审批中(1) / 已驳回(3) / 已撤回(4) 均为未报销。
func isReimbursedStatus(status int8) bool {
return status == reimbursementStatusApproved || status == reimbursementStatusPaid
}
// UpdateStatus POST /backend/reimbursements/:id/status — 报销人确认款项到账(置为「已打款」)。
// 其它状态均由审批引擎/流程动作推进,不再支持手工变更。
func (c *BackendReimburseController) UpdateStatus() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
id, ok := c.getID()
if !ok {
c.reply(400, "无效ID", nil)
return
}
raw, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil {
c.reply(400, "参数错误", nil)
return
}
var payload reimbursementStatusPayload
if err := json.Unmarshal(raw, &payload); err != nil {
c.reply(400, "参数错误", nil)
return
}
newStatus, code, msg := reimburseUpdateStatusCore(claims, id, payload.Status)
if msg != "" {
c.reply(code, msg, nil)
return
}
c.reply(200, "success", map[string]interface{}{"status": newStatus})
}
// reimburseUpdateStatusCore 报销人确认款项到账共享逻辑。
//
// 规则:审批通过后,报销金额是否到账由报销人本人确认;确认后单据置为「已打款」。
// 草稿/审批中/已通过/已驳回/已撤回等状态一律由流程动作与审批引擎推进,不再支持手工修改。
// 返回 (生效状态, HTTP 状态码, 错误信息)。
func reimburseUpdateStatusCore(claims *jwtutil.Claims, id uint64, status int8) (int8, int, string) {
if status != reimbursementStatusPaid {
return 0, 400, "仅支持确认收款,其它状态由审批流程自动推进"
}
row, err := findOwnedReimbursement(claims, id)
if err != nil {
return 0, 404, "报销单不存在"
}
if row.Status == reimbursementStatusPaid {
return row.Status, 200, ""
}
if row.Status != reimbursementStatusApproved {
return 0, 400, "仅审批通过的报销单可以确认到账"
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.BackendReimbursement)).Filter("id", id).Update(orm.Params{
"status": reimbursementStatusPaid, "update_time": now,
})
if err != nil {
return 0, 500, "更新状态失败"
}
return reimbursementStatusPaid, 200, ""
}
// Records GET /backend/reimbursements/:id/records
func (c *BackendReimburseController) Records() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
id, ok := c.getID()
if !ok {
c.reply(400, "无效ID", nil)
return
}
rows, code, msg := reimburseRecordsData(claims, id)
if msg != "" {
c.reply(code, msg, nil)
return
}
c.reply(200, "success", rows)
}
// reimburseRecordsData 审批记录共享逻辑(按 step、id 升序)。
func reimburseRecordsData(claims *jwtutil.Claims, id uint64) ([]models.BackendApprovalRecord, int, string) {
if _, err := findOwnedReimbursement(claims, id); err != nil {
return nil, 404, "报销单不存在"
}
var rows []models.BackendApprovalRecord
_, err := models.Orm.QueryTable(new(models.BackendApprovalRecord)).
Filter("tid", claims.TenantId).Filter("reimbursement_id", id).Filter("is_deleted", 0).
OrderBy("step", "id").All(&rows)
if err != nil && err != orm.ErrNoRows {
return nil, 500, "查询审批记录失败"
}
if rows == nil {
rows = []models.BackendApprovalRecord{}
}
// 历史记录的 approver_name 可能为空(旧令牌没有 username 字段),读取时补齐
tid := uint64(claims.TenantId)
for i := range rows {
if strings.TrimSpace(rows[i].ApproverName) != "" {
continue
}
name := resolveReimburseUserName(tid, rows[i].ApproverID)
if name == "" {
name = "系统"
}
rows[i].ApproverName = name
}
return rows, 200, ""
}
type expenseTypePayload struct {
Name string `json:"name"`
SortOrder int `json:"sort_order"`
IsActive *int8 `json:"is_active"`
}
func expenseTypeScopeCond(tenantID int) *orm.Condition {
cond := orm.NewCondition()
return cond.Or("tid", 0).Or("tid", tenantID)
}
func expenseTypeDTO(row models.BackendReimbursementExpenseType, tenantID int) map[string]interface{} {
isGlobal := row.Tid == 0
return map[string]interface{}{
"id": row.ID, "tid": row.Tid, "name": row.Name,
"sort_order": row.SortOrder, "is_active": row.IsActive,
"is_global": isGlobal, "editable": !isGlobal && row.Tid == uint64(tenantID),
"create_time": row.CreateTime, "update_time": row.UpdateTime,
}
}
func (c *BackendReimburseController) queryExpenseTypes(tenantID int, activeOnly bool) ([]models.BackendReimbursementExpenseType, error) {
return queryReimburseExpenseTypes(tenantID, activeOnly)
}
// queryReimburseExpenseTypes 费用类型查询共享逻辑:全局通用(tid=0)+ 当前租户专有。
func queryReimburseExpenseTypes(tenantID int, activeOnly bool) ([]models.BackendReimbursementExpenseType, error) {
qs := models.Orm.QueryTable(new(models.BackendReimbursementExpenseType)).
SetCond(expenseTypeScopeCond(tenantID)).
Filter("is_deleted", 0)
if activeOnly {
qs = qs.Filter("is_active", 1)
}
var rows []models.BackendReimbursementExpenseType
_, err := qs.OrderBy("sort_order", "id").All(&rows)
if err != nil && err != orm.ErrNoRows {
return nil, err
}
if rows == nil {
rows = []models.BackendReimbursementExpenseType{}
}
return rows, nil
}
func (c *BackendReimburseController) getTypeID() (uint64, bool) {
id, err := strconv.ParseUint(c.Ctx.Input.Param(":type_id"), 10, 64)
return id, err == nil && id > 0
}
func (c *BackendReimburseController) findTenantExpenseType(id uint64, tenantID int) (*models.BackendReimbursementExpenseType, error) {
row := &models.BackendReimbursementExpenseType{}
err := models.Orm.QueryTable(new(models.BackendReimbursementExpenseType)).
Filter("id", id).
Filter("tid", tenantID).
Filter("is_deleted", 0).
One(row)
return row, err
}
func (c *BackendReimburseController) parseExpenseTypePayload() (expenseTypePayload, error) {
var payload expenseTypePayload
raw, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil {
return payload, err
}
err = json.Unmarshal(raw, &payload)
return payload, err
}
// Types GET /backend/reimbursements/types — 下拉选项:全局通用 + 当前租户专有(仅启用)
func (c *BackendReimburseController) Types() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
rows, err := c.queryExpenseTypes(claims.TenantId, true)
if err != nil {
c.reply(500, "查询费用类型失败", nil)
return
}
list := make([]map[string]interface{}, 0, len(rows))
for _, row := range rows {
list = append(list, expenseTypeDTO(row, claims.TenantId))
}
c.reply(200, "success", list)
}
// TypesManage GET /backend/reimbursements/types/manage — 管理列表:含禁用项,标注是否可编辑
func (c *BackendReimburseController) TypesManage() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
rows, err := c.queryExpenseTypes(claims.TenantId, false)
if err != nil {
c.reply(500, "查询费用类型失败", nil)
return
}
list := make([]map[string]interface{}, 0, len(rows))
for _, row := range rows {
list = append(list, expenseTypeDTO(row, claims.TenantId))
}
c.reply(200, "success", list)
}
// CreateType POST /backend/reimbursements/types — 租户新增专有费项
func (c *BackendReimburseController) CreateType() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
payload, err := c.parseExpenseTypePayload()
if err != nil {
c.reply(400, "参数错误", nil)
return
}
name := strings.TrimSpace(payload.Name)
if name == "" {
c.reply(400, "费项名称不能为空", nil)
return
}
isActive := int8(1)
if payload.IsActive != nil {
isActive = *payload.IsActive
}
uid := uint64(claims.UserID)
row := &models.BackendReimbursementExpenseType{
Tid: uint64(claims.TenantId), UID: &uid,
Name: name, SortOrder: payload.SortOrder, IsActive: isActive,
}
if _, err := models.Orm.Insert(row); err != nil {
if strings.Contains(err.Error(), "Duplicate") || strings.Contains(err.Error(), "1062") {
c.reply(400, "该费项名称已存在", nil)
return
}
c.reply(500, "新增费项失败", nil)
return
}
c.reply(200, "success", expenseTypeDTO(*row, claims.TenantId))
}
// UpdateType POST /backend/reimbursements/types/:type_id — 仅可修改本租户专有费项
func (c *BackendReimburseController) UpdateType() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
id, ok := c.getTypeID()
if !ok {
c.reply(400, "无效ID", nil)
return
}
row, err := c.findTenantExpenseType(id, claims.TenantId)
if err != nil {
c.reply(404, "费项不存在或无权修改", nil)
return
}
payload, err := c.parseExpenseTypePayload()
if err != nil {
c.reply(400, "参数错误", nil)
return
}
if name := strings.TrimSpace(payload.Name); name != "" {
row.Name = name
}
if payload.SortOrder >= 0 {
row.SortOrder = payload.SortOrder
}
if payload.IsActive != nil {
row.IsActive = *payload.IsActive
}
now := time.Now()
row.UpdateTime = &now
if _, err := models.Orm.Update(row); err != nil {
if strings.Contains(err.Error(), "Duplicate") || strings.Contains(err.Error(), "1062") {
c.reply(400, "该费项名称已存在", nil)
return
}
c.reply(500, "更新费项失败", nil)
return
}
c.reply(200, "success", expenseTypeDTO(*row, claims.TenantId))
}
// DeleteType DELETE /backend/reimbursements/types/:type_id — 仅可删除本租户专有费项
func (c *BackendReimburseController) DeleteType() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
id, ok := c.getTypeID()
if !ok {
c.reply(400, "无效ID", nil)
return
}
row, err := c.findTenantExpenseType(id, claims.TenantId)
if err != nil {
c.reply(404, "费项不存在或无权删除", nil)
return
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.BackendReimbursementExpenseType)).
Filter("id", row.ID).
Update(orm.Params{"is_deleted": 1, "delete_time": now, "update_time": now})
if err != nil {
c.reply(500, "删除费项失败", nil)
return
}
c.reply(200, "success", nil)
}
// Flows GET /backend/reimbursements/flows
func (c *BackendReimburseController) Flows() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
var rows []models.BackendApprovalFlow
_, err := models.Orm.QueryTable(new(models.BackendApprovalFlow)).
Filter("tid", claims.TenantId).Filter("is_active", 1).Filter("is_deleted", 0).
OrderBy("step_order").All(&rows)
if err != nil && err != orm.ErrNoRows {
c.reply(500, "查询审批流程失败", nil)
return
}
if rows == nil {
rows = []models.BackendApprovalFlow{}
}
c.reply(200, "success", rows)
}
func validateItems(items []reimbursementItemPayload) (float64, error) {
var total float64
for i, item := range items {
if strings.TrimSpace(item.ExpenseType) == "" {
return 0, fmt.Errorf("第%d条费用类型不能为空", i+1)
}
if _, err := parseReimburseDate(item.ExpenseDate); err != nil {
return 0, fmt.Errorf("第%d条费用日期格式错误", i+1)
}
if item.Amount <= 0 {
return 0, fmt.Errorf("第%d条费用金额必须大于0", i+1)
}
if item.Quantity <= 0 {
item.Quantity = 1
}
total += item.Amount
}
return total, nil
}
func saveReimbursement(row *models.BackendReimbursement) error {
if row.ID == 0 {
id, err := models.Orm.Insert(row)
if err != nil {
return err
}
row.ID = uint64(id)
return nil
}
_, err := models.Orm.Update(row)
return err
}
func nullableReimburseString(value string) *string {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
return &value
}
func uint64Ptr(value uint64) *uint64 {
return &value
}
// Dashboard GET /backend/reimbursements/dashboard — 报销统计(周/月/年 + 近6个月趋势 + 状态分布)
func (c *BackendReimburseController) Dashboard() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
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())
var rows []models.BackendReimbursement
_, err := models.Orm.QueryTable(new(models.BackendReimbursement)).
Filter("tid", claims.TenantId).
Filter("user_id", claims.UserID).
Filter("is_deleted", 0).
All(&rows)
if err != nil {
c.reply(500, "查询失败", nil)
return
}
// ponytail: 个人报销单量小,一次全量查询内存聚合;量大时改 SQL GROUP BY
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 isReimbursedStatus(row.Status) { // 已通过 / 已打款均视为已报销
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{}{}
for i := range rows {
row := &rows[i]
// 按报销单「创建日期」归期(周/月/年 + 趋势)。
// 申请日期 apply_date 允许回填为历史日期,用它会导致本月/本周漏统计。
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
}
}
paidIDList := make([]uint64, 0, len(paidIDs))
for id := range paidIDs {
paidIDList = append(paidIDList, id)
}
typeAgg := map[string]*struct {
count int
amount float64
}{}
if len(paidIDList) > 0 {
// 按已打款主单 ID 查明细,不依赖明细行的 tid/uid(历史数据可能不一致)
var items []models.BackendReimbursementItem
_, err = models.Orm.QueryTable(new(models.BackendReimbursementItem)).
Filter("reimbursement_id__in", paidIDList).
Filter("is_deleted", 0).
All(&items)
if err != nil {
c.reply(500, "查询失败", nil)
return
}
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
}
}
expenseTypes := make([]map[string]interface{}, 0, len(typeAgg))
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)
})
c.reply(200, "success", map[string]interface{}{
"week": weekStat, "month": monthStat, "year": yearStat,
"trend": trend, "status_dist": statusDist, "expense_types": expenseTypes,
})
}