优化报销管理增加挂靠
This commit is contained in:
1 parent
7b1c94b5ff
commit
ee49ef15b5
24 files changed
+1267
-70
No files matched your search
@@ -37,6 +37,10 @@ type reimbursementItemPayload struct {
|
||||
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 {
|
||||
@@ -294,6 +298,15 @@ func applyReceiptToItem(item *models.BackendReimbursementItem, payload reimburse
|
||||
}
|
||||
|
||||
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
|
||||
@@ -315,6 +328,9 @@ func reimbursementItemDTO(item models.BackendReimbursementItem, scheme, host str
|
||||
"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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,8 +363,10 @@ func (c *BackendReimburseController) List() {
|
||||
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)
|
||||
data, code, msg := reimburseListData(claims, page, pageSize, keyword, statusErr == nil, status, reimbursed)
|
||||
if msg != "" {
|
||||
c.reply(code, msg, nil)
|
||||
return
|
||||
@@ -357,8 +375,9 @@ func (c *BackendReimburseController) List() {
|
||||
}
|
||||
|
||||
// reimburseListData 报销单列表共享逻辑:Web 后台与移动端(/api/oa/expense/list)共用。
|
||||
// reimbursed 供 Web 列表的「全部/未报销/已报销」tab 使用,移动端传空串保持原行为。
|
||||
// 返回 (数据, HTTP 状态码, 错误信息),错误信息为空串表示成功。
|
||||
func reimburseListData(claims *jwtutil.Claims, page, pageSize int, keyword string, hasStatus bool, status int) (map[string]interface{}, int, string) {
|
||||
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
|
||||
}
|
||||
@@ -401,6 +420,20 @@ func reimburseListData(claims *jwtutil.Claims, page, pageSize int, keyword strin
|
||||
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)
|
||||
}
|
||||
@@ -697,6 +730,232 @@ func validateReimbursementItem(item reimbursementItemPayload) (time.Time, error)
|
||||
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 {
|
||||
@@ -835,16 +1094,31 @@ func reimburseAddItemCore(claims *jwtutil.Claims, reimbursementID uint64, payloa
|
||||
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 {
|
||||
@@ -904,10 +1178,27 @@ func reimburseUpdateItemCore(claims *jwtutil.Claims, reimbursementID, itemID uin
|
||||
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
|
||||
@@ -1300,6 +1591,13 @@ const (
|
||||
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() {
|
||||
@@ -1729,7 +2027,7 @@ func (c *BackendReimburseController) Dashboard() {
|
||||
addTo := func(stat map[string]interface{}, row *models.BackendReimbursement) {
|
||||
stat["total"] = stat["total"].(float64) + row.TotalAmount
|
||||
stat["count"] = stat["count"].(int) + 1
|
||||
if row.Status == 5 { // 已打款视为已报销
|
||||
if isReimbursedStatus(row.Status) { // 已通过 / 已打款均视为已报销
|
||||
stat["paid"] = stat["paid"].(float64) + row.TotalAmount
|
||||
} else {
|
||||
stat["unpaid"] = stat["unpaid"].(float64) + row.TotalAmount
|
||||
|
||||
Reference in new issue
Block a user