Files
yunzerwebsiteallinone/go/controllers/backend_reimburse.go
T
2026-10-17 11:52:02 +08:00

1682 lines
50 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"
"errors"
"fmt"
"io"
"path"
"sort"
"strconv"
"strings"
"time"
"server/models"
"server/pkg/jwtutil"
"server/services"
"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"`
}
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) {
auth := strings.TrimSpace(c.Ctx.Request.Header.Get("Authorization"))
parts := strings.SplitN(auth, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
return nil, false
}
claims, err := jwtutil.ParseToken(parts[1])
if err != nil || claims.UserType != "backend" || 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)
}
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{} {
receiptFiles := resolveReceiptFiles(scheme, host, item)
receiptURLs := strings.TrimSpace(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,
}
}
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")
data, code, msg := reimburseListData(claims, page, pageSize, keyword, statusErr == nil, status)
if msg != "" {
c.reply(code, msg, nil)
return
}
c.reply(200, "success", data)
}
// reimburseListData 报销单列表共享逻辑:Web 后台与移动端(/api/oa/expense/list)共用。
// 返回 (数据, HTTP 状态码, 错误信息),错误信息为空串表示成功。
func reimburseListData(claims *jwtutil.Claims, page, pageSize int, keyword string, hasStatus bool, status int) (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)
}
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 {
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
_, err = models.Orm.QueryTable(new(models.BackendReimbursementItem)).
Filter("reimbursement_id", id).Filter("tid", claims.TenantId).Filter("is_deleted", 0).
OrderBy("id").All(&items)
if err != nil && err != orm.ErrNoRows {
return nil, 500, "查询明细失败: " + err.Error()
}
return reimbursementDTO(*row, items, scheme, host), 200, ""
}
// 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 创建报销单(草稿)共享逻辑。
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: 0, ApplyDate: applyDate,
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 row.Status != 0 {
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 != 0 {
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 查询本人报销单下的费用明细(包级共享,移动端复用)。
func findOwnedReimbursementItem(claims *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("tid", claims.TenantId).
Filter("uid", claims.UserID).
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
}
// ensureDraftReimbursement 校验报销单存在且为草稿(包级共享,移动端复用)。
// 返回 (报销单, HTTP 状态码, 错误信息),错误信息为空串表示通过。
func ensureDraftReimbursement(claims *jwtutil.Claims, id uint64) (*models.BackendReimbursement, int, string) {
row, err := findOwnedReimbursement(claims, id)
if err != nil {
return nil, 404, "报销单不存在"
}
if row.Status != 0 {
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 := ensureDraftReimbursement(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()
}
uid := uint64(claims.UserID)
quantity := payload.Quantity
if quantity <= 0 {
quantity = 1
}
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),
}
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 := ensureDraftReimbursement(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()
}
quantity := payload.Quantity
if quantity <= 0 {
quantity = 1
}
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 := ensureDraftReimbursement(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)
if msg != "" {
c.reply(code, msg, nil)
return
}
c.reply(200, "success", nil)
}
// reimburseActPayload 审批操作请求体。
type reimburseActPayload struct {
Comment string `json:"comment"`
}
// readReimburseActPayload 读取审批意见(可空)。
func (c *BackendReimburseController) readReimburseActPayload() string {
var payload reimburseActPayload
raw, err := io.ReadAll(c.Ctx.Request.Body)
if err == nil && len(raw) > 0 {
_ = json.Unmarshal(raw, &payload)
}
return strings.TrimSpace(payload.Comment)
}
// 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 提交审批共享逻辑:仅草稿可提交,需租户已配置启用审批流,
// 提交后状态置为审批中(1)并写入 action=1 的审批记录。
// 流程匹配与实例/任务创建走运行期引擎(services/approval):
// 实例落 yz_approval_instance,首节点待办落 yz_approval_task,
// 审批人在「我的待办」里处理(reimburseActCore)。
func reimburseSubmitCore(claims *jwtutil.Claims, id uint64) (int, string) {
row, err := findOwnedReimbursement(claims, id)
if err != nil {
return 404, "报销单不存在"
}
if row.Status != 0 {
return 400, "当前状态不可提交"
}
var deptID uint64
if row.DepartmentID != nil {
deptID = *row.DepartmentID
}
// 依次尝试回退类型匹配流程;有部门限制的流程未命中时由引擎继续尝试通用流
var instID uint64
var submitErr error
for _, bizType := range reimburseFallbackBizTypes {
instID, submitErr = approval.Submit(approval.SubmitInput{
Scope: models.ApprovalScopeTenant,
Tid: uint64(claims.TenantId),
BizTid: uint64(claims.TenantId),
BizType: bizType,
BizID: id,
BizTitle: claims.Username + "的报销单",
SubmitUID: uint64(claims.UserID),
SubmitName: claims.Username,
DeptID: deptID,
})
if submitErr == nil {
break
}
var noFlow *approval.ErrNoFlow
if !errors.As(submitErr, &noFlow) {
return 400, submitErr.Error()
}
}
if submitErr != nil {
return 400, "尚未配置审批流程"
}
// 实例可能无审批节点直接通过(如流程只有通知),按实例实际状态落单据状态
var inst models.ApprovalInstance
if err := models.Orm.QueryTable(new(models.ApprovalInstance)).
Filter("id", instID).One(&inst); err != nil {
return 500, "查询审批实例失败"
}
newStatus := int8(1) // 审批中
if inst.Status == models.ApprovalInstanceApproved {
newStatus = 2
}
now := time.Now()
if _, err := models.Orm.QueryTable(new(models.BackendReimbursement)).Filter("id", id).Update(orm.Params{
"status": newStatus, "current_approver_id": 0, "update_time": now,
}); err != nil {
return 500, "提交失败"
}
if _, err := models.Orm.Insert(&models.BackendApprovalRecord{
Tid: uint64(claims.TenantId), UID: uint64Ptr(uint64(claims.UserID)), ReimbursementID: id,
ApproverID: uint64(claims.UserID), ApproverName: claims.Username, Action: 1,
}); err != nil {
return 500, "提交审批记录失败"
}
return 200, ""
}
// findRunningReimburseInstance 按报销单 ID 查运行中的审批实例
// (实例的 biz_type 取发起时命中的类型,按回退顺序逐一查找)。
func findRunningReimburseInstance(tid, id uint64) (*models.ApprovalInstance, error) {
for _, bizType := range reimburseFallbackBizTypes {
var inst models.ApprovalInstance
err := models.Orm.QueryTable(new(models.ApprovalInstance)).
Filter("tid", tid).Filter("biz_type", bizType).Filter("biz_id", id).
Filter("status", models.ApprovalInstanceRunning).Filter("is_deleted", 0).
OrderBy("-id").One(&inst)
if err == nil {
return &inst, nil
}
if err != orm.ErrNoRows {
return nil, err
}
}
return nil, orm.ErrNoRows
}
// reimburseMyTaskCore 查当前用户在指定报销单上的待审批任务(无则返回 nil)。
func reimburseMyTaskCore(claims *jwtutil.Claims, id uint64) (*models.ApprovalTask, error) {
inst, err := findRunningReimburseInstance(uint64(claims.TenantId), id)
if err != nil {
return nil, nil // 无运行中实例:不是「错误」,只是没有待办
}
var task models.ApprovalTask
err = models.Orm.QueryTable(new(models.ApprovalTask)).
Filter("instance_id", inst.ID).
Filter("approver_id", claims.UserID).
Filter("status", models.ApprovalTaskPending).
Filter("is_deleted", 0).
One(&task)
if err == orm.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &task, nil
}
// reimburseActCore 审批操作共享逻辑:当前用户必须是该单当前节点的待办审批人,
// 通过/驳回经运行期引擎推进(或签/会签/多级节点均由引擎处理),
// 实例结束时同步报销单状态,并写审批记录保持时间线兼容。
// 返回 (HTTP 状态码, 错误信息, 成功提示),错误信息为空串表示成功。
func reimburseActCore(claims *jwtutil.Claims, id uint64, pass bool, comment string) (int, string, string) {
inst, err := findRunningReimburseInstance(uint64(claims.TenantId), id)
if err != nil {
return 404, "该报销单不在审批中", ""
}
var task models.ApprovalTask
err = models.Orm.QueryTable(new(models.ApprovalTask)).
Filter("instance_id", inst.ID).
Filter("approver_id", claims.UserID).
Filter("status", models.ApprovalTaskPending).
Filter("is_deleted", 0).
One(&task)
if err == orm.ErrNoRows {
return 400, "您没有该单据的待审批任务", ""
}
if err != nil {
return 500, "查询审批任务失败", ""
}
result, err := approval.Approve(task.ID, approval.ApproveInput{
ApproverID: uint64(claims.UserID),
Pass: pass,
Comment: comment,
})
if err != nil {
return 400, err.Error(), ""
}
// 业务回调:写时间线记录 + 实例结束时同步单据状态
applyReimburseApprovalResult(uint64(claims.TenantId), id,
uint64(claims.UserID), claims.Username, task.Sort,
pass, comment, result.Finished, result.InstanceStat)
hint := result.Message
if hint == "" {
hint = "操作成功"
}
return 200, "", hint
}
// isReimburseBizType 业务类型是否归属报销(提交与实例落库的 biz_type 均可能命中)。
func isReimburseBizType(bizType string) bool {
for _, bt := range reimburseFallbackBizTypes {
if bt == bizType {
return true
}
}
return false
}
// applyReimburseApprovalResult 报销业务回调(报销页与审批中心两处审批入口共用):
// - 始终写一条审批记录(action: 2通过 3驳回),与既有时间线保持兼容;
// - 实例结束时同步报销单状态:通过→2,驳回→3。
func applyReimburseApprovalResult(tid, bizID, approverID uint64, approverName string,
taskSort int, pass bool, comment string, finished bool, instStat int8) {
action := int8(models.ApprovalActionApprove)
if !pass {
action = models.ApprovalActionReject
}
if _, err := models.Orm.Insert(&models.BackendApprovalRecord{
Tid: tid, UID: uint64Ptr(approverID), ReimbursementID: bizID,
ApproverID: approverID, ApproverName: approverName, Action: action,
Comment: &comment, Step: int8(taskSort),
}); err != nil {
return
}
if finished {
newStatus := int8(3)
if instStat == models.ApprovalInstanceApproved {
newStatus = 2
}
_, _ = models.Orm.QueryTable(new(models.BackendReimbursement)).Filter("id", bizID).
Update(orm.Params{"status": newStatus, "update_time": time.Now()})
}
}
type reimbursementStatusPayload struct {
Status int8 `json:"status"`
}
var manualConfirmStatuses = map[int8]bool{
0: true, // 草稿
1: true, // 审批中
2: true, // 已通过
3: true, // 已驳回
4: true, // 已撤回
5: true, // 已打款
}
// 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 !manualConfirmStatuses[status] {
return 0, 400, "不支持的状态值"
}
row, err := findOwnedReimbursement(claims, id)
if err != nil {
return 0, 404, "报销单不存在"
}
if row.Status == status {
return row.Status, 200, ""
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.BackendReimbursement)).Filter("id", id).Update(orm.Params{
"status": status, "update_time": now,
})
if err != nil {
return 0, 500, "更新状态失败"
}
return status, 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{}
}
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 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{}{}
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
}
}
var items []models.BackendReimbursementItem
_, err = models.Orm.QueryTable(new(models.BackendReimbursementItem)).
Filter("tid", claims.TenantId).
Filter("uid", claims.UserID).
Filter("is_deleted", 0).
All(&items)
if err != nil {
c.reply(500, "查询失败", nil)
return
}
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
}
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,
})
}