package controllers import ( "encoding/json" "fmt" "io" "path" "strconv" "strings" "time" "server/models" "server/pkg/jwtutil" "server/services" "github.com/beego/beego/v2/client/orm" beego "github.com/beego/beego/v2/server/web" ) // 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"` } 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 } func (c *BackendReimburseController) findOwned(id uint64, claims *jwtutil.Claims) (*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 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, ",") 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]) } files = append(files, map[string]interface{}{ "file_id": id, "url": url, "name": name, }) } return files } func syncReceiptFields(item *models.BackendReimbursementItem, scheme, host string) { files := resolveReceiptFiles(scheme, host, *item) if len(files) == 0 { item.ReceiptFileIDs = "" item.ReceiptURLs = "" 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) { if payload.ReceiptFlag == 0 && strings.TrimSpace(payload.ReceiptFileIDs) == "" { item.ReceiptFileIDs = "" item.ReceiptURLs = "" item.ReceiptFlag = 0 return } if strings.TrimSpace(payload.ReceiptFileIDs) != "" { item.ReceiptFileIDs = strings.TrimSpace(payload.ReceiptFileIDs) syncReceiptFields(item, scheme, host) return } item.ReceiptFlag = payload.ReceiptFlag item.ReceiptURLs = strings.TrimSpace(payload.ReceiptURLs) if item.ReceiptURLs == "" && item.ReceiptFlag == 0 { item.ReceiptFileIDs = "" } } 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_files": receiptFiles, } } func reimbursementDTO(row models.BackendReimbursement, items []models.BackendReimbursementItem, scheme, host string) map[string]interface{} { result := 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, } 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) if page < 1 { page = 1 } if pageSize < 1 || pageSize > 100 { pageSize = 10 } keyword := strings.TrimSpace(c.GetString("keyword")) status, statusErr := c.GetInt("status") hasStatus := statusErr == nil qs := models.Orm.QueryTable(new(models.BackendReimbursement)). Filter("tid", claims.TenantId).Filter("user_id", claims.UserID).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 { c.reply(500, "查询失败", nil) return } var rows []models.BackendReimbursement _, err = qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&rows) if err != nil && err != orm.ErrNoRows { c.reply(500, "查询失败", nil) return } 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, }) } c.reply(200, "success", map[string]interface{}{"list": list, "total": total}) } // 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 } row, err := c.findOwned(id, claims) if err != nil { c.reply(404, "报销单不存在", nil) return } 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 { c.reply(500, "查询明细失败: "+err.Error(), nil) return } scheme, host := services.PublicRequestBaseURL(&c.Controller) c.reply(200, "success", reimbursementDTO(*row, items, scheme, host)) } // 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 } applyDate, err := parseReimburseDate(payload.ApplyDate) if err != nil { c.reply(400, "申请日期格式错误", nil) return } 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 { c.reply(500, "创建报销单失败", nil) return } c.reply(200, "success", map[string]interface{}{"id": row.ID}) } // 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 } row, err := c.findOwned(id, claims) if err != nil { c.reply(404, "报销单不存在", nil) return } if row.Status != 0 { c.reply(400, "只有草稿可以编辑", nil) return } payload, err := c.parsePayload() if err != nil { c.reply(400, "参数错误", nil) return } applyDate, err := parseReimburseDate(payload.ApplyDate) if err != nil { c.reply(400, "申请日期格式错误", nil) return } row.ApplyDate, row.Description = applyDate, nullableReimburseString(payload.Description) if err := saveReimbursement(row); err != nil { c.reply(500, "更新报销单失败", nil) return } c.reply(200, "success", map[string]interface{}{"id": row.ID}) } // 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 } row, err := c.findOwned(id, claims) if err != nil { c.reply(404, "报销单不存在", nil) return } if row.Status != 0 { c.reply(400, "只有草稿可以删除", nil) return } 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 { c.reply(500, "删除失败", nil) return } _, _ = models.Orm.QueryTable(new(models.BackendReimbursementItem)). Filter("reimbursement_id", id).Update(orm.Params{"is_deleted": 1, "delete_time": now}) c.reply(200, "success", nil) } 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 } func (c *BackendReimburseController) findOwnedItem(id, reimbursementID uint64, claims *jwtutil.Claims) (*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 } func (c *BackendReimburseController) ensureDraftReimbursement(id uint64, claims *jwtutil.Claims) (*models.BackendReimbursement, bool) { row, err := c.findOwned(id, claims) if err != nil { c.reply(404, "报销单不存在", nil) return nil, false } if row.Status != 0 { c.reply(400, "只有草稿可以维护费用明细", nil) return nil, false } return row, true } 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 } if _, ok := c.ensureDraftReimbursement(reimbursementID, claims); !ok { return } payload, err := c.parseItemPayload() if err != nil { c.reply(400, "参数错误", nil) return } date, err := validateReimbursementItem(payload) if err != nil { c.reply(400, err.Error(), nil) return } uid := uint64(claims.UserID) quantity := payload.Quantity if quantity <= 0 { quantity = 1 } scheme, host := services.PublicRequestBaseURL(&c.Controller) 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 { c.reply(500, "新增费用明细失败", nil) return } if err := recalculateReimbursementTotal(reimbursementID); err != nil { c.reply(500, "更新报销总额失败", nil) return } c.reply(200, "success", reimbursementItemDTO(*item, scheme, host)) } // 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 } if _, ok := c.ensureDraftReimbursement(reimbursementID, claims); !ok { return } item, err := c.findOwnedItem(itemID, reimbursementID, claims) if err != nil { c.reply(404, "费用明细不存在", nil) return } payload, err := c.parseItemPayload() if err != nil { c.reply(400, "参数错误", nil) return } date, err := validateReimbursementItem(payload) if err != nil { c.reply(400, err.Error(), nil) return } quantity := payload.Quantity if quantity <= 0 { quantity = 1 } scheme, host := services.PublicRequestBaseURL(&c.Controller) 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 { c.reply(500, "更新费用明细失败", nil) return } if err := recalculateReimbursementTotal(reimbursementID); err != nil { c.reply(500, "更新报销总额失败", nil) return } c.reply(200, "success", reimbursementItemDTO(*item, scheme, host)) } // 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 } if _, ok := c.ensureDraftReimbursement(reimbursementID, claims); !ok { return } item, err := c.findOwnedItem(itemID, reimbursementID, claims) if err != nil { c.reply(404, "费用明细不存在", nil) return } 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 { c.reply(500, "删除费用明细失败", nil) return } if err := recalculateReimbursementTotal(reimbursementID); err != nil { c.reply(500, "更新报销总额失败", nil) return } c.reply(200, "success", nil) } // 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 } row, err := c.findOwned(id, claims) if err != nil { c.reply(404, "报销单不存在", nil) return } if row.Status != 0 { c.reply(400, "当前状态不可提交", nil) return } var flow models.BackendApprovalFlow err = models.Orm.QueryTable(new(models.BackendApprovalFlow)). Filter("tid", claims.TenantId).Filter("is_active", 1).Filter("is_deleted", 0). OrderBy("step_order").One(&flow) if err != nil { c.reply(400, "尚未配置审批流程", nil) return } now := time.Now() _, err = models.Orm.QueryTable(new(models.BackendReimbursement)).Filter("id", id).Update(orm.Params{ "status": 1, "current_step": flow.StepOrder, "current_approver_id": 0, "update_time": now, }) if err != nil { c.reply(500, "提交失败", nil) return } _, 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, Step: flow.StepOrder, PrevStep: 0, }) if err != nil { c.reply(500, "提交审批记录失败", nil) return } c.reply(200, "success", nil) } 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 } if !manualConfirmStatuses[payload.Status] { c.reply(400, "不支持的状态值", nil) return } row, err := c.findOwned(id, claims) if err != nil { c.reply(404, "报销单不存在", nil) return } if row.Status == payload.Status { c.reply(200, "success", map[string]interface{}{"status": row.Status}) return } now := time.Now() _, err = models.Orm.QueryTable(new(models.BackendReimbursement)).Filter("id", id).Update(orm.Params{ "status": payload.Status, "update_time": now, }) if err != nil { c.reply(500, "更新状态失败", nil) return } c.reply(200, "success", map[string]interface{}{"status": payload.Status}) } // 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 } if _, err := c.findOwned(id, claims); err != nil { c.reply(404, "报销单不存在", nil) return } 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 { c.reply(500, "查询审批记录失败", nil) return } if rows == nil { rows = []models.BackendApprovalRecord{} } c.reply(200, "success", rows) } 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) { 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 }