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"` } 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) } 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 { // 审批已结束但业务状态未及时回写时,读取即自愈 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 } // 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() } 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 := 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() } 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 := 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 // 已打款 ) // 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 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 } } 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, }) }