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