Files
yunzerwebsiteallinone/go/controllers/backend_reimburse.go
T

1065 lines
30 KiB
Go

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
}