优化报销管理增加挂靠

This commit is contained in:
hero920103 committed 2026-11-06 01:13:28 +08:00
1 parent 7b1c94b5ff
commit ee49ef15b5
24 files changed
+1267 -70

No files matched your search

+1 -1
View File
@@ -55,7 +55,7 @@ func (c *AppOaController) ExpenseList() {
status, statusErr := c.GetInt("status")
data, code, msg := reimburseListData(claims, page, pageSize,
strings.TrimSpace(c.GetString("keyword")), statusErr == nil, status)
strings.TrimSpace(c.GetString("keyword")), statusErr == nil, status, "")
if msg != "" {
c.oaErr(code, code, msg)
return
+76 -6
View File
@@ -35,14 +35,28 @@ func (c *AuthLoginController) LoginPage() {
step := c.GetString("step")
errMsg := c.GetString("error")
// 已登录且已选企业:直接放行到 authorize,无需再输密码
// 已登录且已选企业:直接放行,无需再输密码。
// 即使 redirect 丢失也要放行到业务站首页,否则会一直停留在登录页,
// 表现为「登录后还是跳回登录界面」。
sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName))
if sid != "" && step != "tenant" {
if session, err := authsvc.GetSession(sid); err == nil && session.Tid != authsvc.PendingTenantID {
if back := decodeRedirect(redirect); back != "" {
if session, err := authsvc.GetSession(sid); err == nil {
if session.Tid == authsvc.PendingTenantID {
// 已登录但还没选企业:直接带到企业选择步骤,
// 不要再渲染一次登录表单让用户重新登录。
target := "/auth/login?step=tenant&client_id=" + url.QueryEscape(clientID) +
"&redirect=" + url.QueryEscape(redirect)
c.Redirect(target, 302)
return
}
if back := safeBackURL(clientID, redirect); back != "" {
c.Redirect(back, 302)
return
}
} else {
// 会话已被吊销/过期:清掉失效 Cookie,避免后续请求继续带上它
clearSessionCookieForCtx(c.Ctx)
sid = ""
}
}
@@ -55,12 +69,17 @@ func (c *AuthLoginController) LoginPage() {
// 只有一家企业直接进入,无需展示选择页
if len(tenants) == 1 && step == "tenant" {
if _, _, err := authsvc.ChooseTenant(sid, tenants[0].Tid, clientID); err == nil {
if back := decodeRedirect(redirect); back != "" {
if back := safeBackURL(clientID, redirect); back != "" {
c.Redirect(back, 302)
return
}
}
}
} else if step == "tenant" {
// 选企业时会话已失效:不要渲染一个空的企业列表,退回登录页重新登录
clearSessionCookieForCtx(c.Ctx)
c.Redirect("/auth/login?client_id="+url.QueryEscape(clientID), 302)
return
}
}
@@ -168,7 +187,7 @@ func (c *AuthLoginController) LoginSubmit() {
return
}
back := decodeRedirect(req.Redirect)
back := safeBackURL(req.ClientID, req.Redirect)
c.serveJSON(map[string]interface{}{
"code": 200,
"msg": "登录成功",
@@ -236,7 +255,7 @@ func (c *AuthLoginController) SwitchTenant() {
c.serveJSON(map[string]interface{}{
"code": 200,
"msg": "切换成功",
"redirect": decodeRedirect(req.Redirect),
"redirect": safeBackURL(req.ClientID, req.Redirect),
"data": map[string]interface{}{"tokens": tokens, "identity": profile},
})
}
@@ -360,6 +379,29 @@ func (c *AuthLoginController) LogoutAction() {
c.serveJSON(map[string]interface{}{"code": 200, "msg": "已登出"})
}
// ClearSiteData 清除当前站点在浏览器中的全部缓存数据
// POST /auth/clear-site-data
//
// 登录页「清除缓存」按钮调用。前端 JS 已清理同源的 Web Storage / Cache
// Storage / IndexedDB / Service Worker,但 yz_sid 是 HttpOnly Cookie,前端
// 读不到也删不掉,因此这里:
// - 用 Set-Cookie 直接删除会话 Cookie(兼容所有浏览器);
// - 返回 Clear-Site-Data 响应头,让浏览器在安全上下文(HTTPS / localhost)
// 下把本 origin 的 cookies / storage / HTTP cache 一并清空。
//
// Clear-Site-Data 严格限定在响应所属 origin,不会影响其它站点。
func (c *AuthLoginController) ClearSiteData() {
// Cookie 被清后服务端会话将无人使用,同步吊销,避免留下仍有效的孤儿会话
// 占用「1 号 1 机」名额。
if sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName)); sid != "" {
_ = authsvc.RevokeSession(sid, models.RevokeReasonLogout)
}
clearSessionCookieForCtx(c.Ctx)
c.Ctx.Output.Header("Clear-Site-Data", `"cache", "cookies", "storage"`)
c.Ctx.Output.Header("Cache-Control", "no-store")
c.serveJSON(map[string]interface{}{"code": 200, "msg": "已清除"})
}
// VerifyConfig 登录验证配置(替代 /backend/login/getOpenVerify)
// GET /auth/verify-config?tid=
//
@@ -391,6 +433,34 @@ func (c *AuthLoginController) VerifyConfig() {
// ---------------------------------------------------------------- 工具
// safeBackURL 计算登录成功后的回跳地址。
//
// 优先使用业务端传来的 authorize 地址(base64url 编码);若链路中 redirect
// 丢失(直接打开登录页、单点登出后再次登录、选企业时参数未透传等),则退回
// 该应用已登记回跳地址所在的站点首页。业务站没有令牌时会自动重新发起 OIDC,
// 认证中心此时已有会话可静默签码完成登录,不会卡在登录页形成循环。
func safeBackURL(clientID, redirect string) string {
if back := decodeRedirect(redirect); back != "" {
return back
}
client, err := findClient(strings.TrimSpace(clientID))
if err != nil || client.RedirectURIs == nil {
return ""
}
var list []string
if err := json.Unmarshal([]byte(*client.RedirectURIs), &list); err != nil {
return ""
}
for _, item := range list {
u, err := url.Parse(strings.TrimSpace(item))
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
continue
}
return u.Scheme + "://" + u.Host + "/"
}
return ""
}
func decodeRedirect(encoded string) string {
if encoded == "" {
return ""
+10 -7
View File
@@ -368,17 +368,17 @@ func authorizePath(c *AuthOidcController) string {
return "/auth/authorize"
}
// clearSessionCookie 清除认证中心会话 Cookie
// clearSessionCookie 清除认证中心会话 Cookie。
// 与写入复用同一实现:http 环境写入的 Cookie 不带 Secure,若清除时硬编码
// Secure,浏览器会拒绝删除,失效的 yz_sid 一直残留,authorize 每次都判定
// 未登录并把用户送回登录页。
func clearSessionCookie(c *AuthOidcController) {
c.Ctx.Output.Header("Set-Cookie",
fmt.Sprintf("%s=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax", sessionCookieName))
clearSessionCookieForCtx(c.Ctx)
}
// setSessionCookie 写入认证中心会话 Cookie
func setSessionCookie(c *AuthOidcController, sid string) {
c.Ctx.Output.Header("Set-Cookie",
fmt.Sprintf("%s=%s; Path=/; Max-Age=%d; HttpOnly; Secure; SameSite=Lax",
sessionCookieName, sid, sessionCookieTTL))
setSessionCookieForCtx(c.Ctx, sid)
}
func bearerToken(c *AuthOidcController) string {
@@ -526,7 +526,10 @@ func exchangeCode(code, verifier, clientID string) (*authsvc.TokenPair, *jwtutil
Filter("code_hash", stored.CodeHash).
Update(map[string]interface{}{"used": 1})
session, err := authsvc.CreateSession(authsvc.SessionInfo{
// 复用登录阶段建立的会话,切勿在此新建:新建会话会触发「1 号 1 机」
// 互踢,把浏览器 yz_sid Cookie 对应的会话吊销,导致下一次回认证中心
// 又看到登录页(登录成功后反复跳回登录界面)。
session, err := authsvc.ReuseSession(authsvc.SessionInfo{
IdentityID: stored.IdentityID,
Tid: stored.Tid,
ClientID: clientID,
+6
View File
@@ -268,6 +268,12 @@ func (c *AuthThirdController) Callback() {
c.Redirect("/auth/authorize?"+v.Encode(), 302)
return
}
// 缺少 OIDC 参数时也要把已登录用户送回业务站,由其重新发起授权流程;
// 直接回登录页会让「登录成功」看起来又变成了未登录。
if back := safeBackURL(st.ClientID, ""); back != "" {
c.Redirect(back, 302)
return
}
c.Redirect("/auth/login", 302)
}
+301 -3
View File
@@ -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
@@ -0,0 +1,71 @@
-- =============================================================
-- OA 报销:费用明细支持「发票挂靠」(已建表环境执行)
--
-- 背景:一张发票(例如 200 元油票/邮费票)常常需要分多次报销才能报满。
-- 现在每条费用明细可以挂靠一张「仍有余额的发票」,并记录本次使用金额。
--
-- 设计:不新建发票表,发票本体仍是明细行 receipt_file_ids 指向的附件。
-- 票面金额取 receipt_amounts(OCR 识别或人工填写),余额由后端实时聚合:
-- 余额 = 票面金额 - 上传该票的明细占用 - 其它明细挂靠占用
-- 明细被删除后占用自动释放,无需额外回滚,也不存在余额字段不一致问题。
--
-- 适用:已有 yz_backend_reimbursement_items 的环境。
-- 幂等:重复执行安全(information_schema 判断列是否存在)。
-- =============================================================
USE `go-platform`;
-- 1) 挂靠的发票文件 ID(0 = 未挂靠)
SET @ddl := (
SELECT IF(
EXISTS (
SELECT 1 FROM `information_schema`.`COLUMNS`
WHERE `TABLE_SCHEMA` = DATABASE()
AND `TABLE_NAME` = 'yz_backend_reimbursement_items'
AND `COLUMN_NAME` = 'invoice_file_id'
),
'SELECT ''invoice_file_id 已存在,跳过'' AS msg',
'ALTER TABLE `yz_backend_reimbursement_items` ADD COLUMN `invoice_file_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''挂靠的发票文件ID(yz_system_files.id,0=未挂靠)'' AFTER `receipt_amounts`'
)
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 2) 本条明细本次使用的发票金额
SET @ddl := (
SELECT IF(
EXISTS (
SELECT 1 FROM `information_schema`.`COLUMNS`
WHERE `TABLE_SCHEMA` = DATABASE()
AND `TABLE_NAME` = 'yz_backend_reimbursement_items'
AND `COLUMN_NAME` = 'invoice_use_amount'
),
'SELECT ''invoice_use_amount 已存在,跳过'' AS msg',
'ALTER TABLE `yz_backend_reimbursement_items` ADD COLUMN `invoice_use_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT ''本条明细挂靠使用的发票金额'' AFTER `invoice_file_id`'
)
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 3) 辅助索引:按挂靠发票快速聚合(重复执行前先判断)
SET @ddl := (
SELECT IF(
EXISTS (
SELECT 1 FROM `information_schema`.`STATISTICS`
WHERE `TABLE_SCHEMA` = DATABASE()
AND `TABLE_NAME` = 'yz_backend_reimbursement_items'
AND `INDEX_NAME` = 'idx_reimb_invoice'
),
'SELECT ''idx_reimb_invoice 已存在,跳过'' AS msg',
'ALTER TABLE `yz_backend_reimbursement_items` ADD INDEX `idx_reimb_invoice` (`reimbursement_id`, `invoice_file_id`)'
)
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 4) 核对结果
SHOW FULL COLUMNS FROM `yz_backend_reimbursement_items`
WHERE `Field` IN ('invoice_file_id', 'invoice_use_amount');
+4
View File
@@ -84,6 +84,10 @@ type BackendReimbursementItem struct {
ReceiptFileIDs string `orm:"column(receipt_file_ids);size(500)" json:"receipt_file_ids"`
ReceiptURLs string `orm:"column(receipt_urls);size(500)" json:"receipt_urls"`
ReceiptAmounts string `orm:"column(receipt_amounts);size(500)" json:"receipt_amounts"`
// 发票挂靠:一张票可分数次报销,这里记录本条明细挂靠的发票文件与本次使用金额。
// 发票余额由后端按「票面金额 - 各明细占用」实时聚合,不额外存余额字段。
InvoiceFileID uint64 `orm:"column(invoice_file_id);default(0)" json:"invoice_file_id"`
InvoiceUseAmount float64 `orm:"column(invoice_use_amount);type(decimal);digits(10);decimals(2);default(0)" json:"invoice_use_amount"`
IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);null;type(datetime)" json:"update_time"`
+1
View File
@@ -39,6 +39,7 @@ func Register() {
// 注意:beego 同一路径重复 Router 会覆盖,故 /auth/logout 只注册一次
beego.Router("/auth/login", &authctl.AuthLoginController{}, "get:LoginPage;post:LoginSubmit")
beego.Router("/auth/logout", &authctl.AuthLoginController{}, "get:LogoutPage;post:LogoutAction")
beego.Router("/auth/clear-site-data", &authctl.AuthLoginController{}, "post:ClearSiteData")
beego.Router("/auth/tenants", &authctl.AuthLoginController{}, "get:Tenants")
beego.Router("/auth/switch-tenant", &authctl.AuthLoginController{}, "post:SwitchTenant")
beego.Router("/auth/sessions", &authctl.AuthLoginController{}, "get:Sessions")
+1
View File
@@ -299,6 +299,7 @@ func RegisterAuthRoutes() {
// 报销与审批管理
beego.Router("/backend/reimbursements", &controllers.BackendReimburseController{}, "get:List;post:Create")
beego.Router("/backend/reimbursements/dashboard", &controllers.BackendReimburseController{}, "get:Dashboard")
beego.Router("/backend/reimbursements/invoices/available", &controllers.BackendReimburseController{}, "get:AvailableInvoices")
beego.Router("/backend/reimbursements/types/manage", &controllers.BackendReimburseController{}, "get:TypesManage")
beego.Router("/backend/reimbursements/types/:type_id", &controllers.BackendReimburseController{}, "post:UpdateType;delete:DeleteType")
beego.Router("/backend/reimbursements/types", &controllers.BackendReimburseController{}, "get:Types;post:CreateType")
+41
View File
@@ -133,6 +133,47 @@ func CreateSession(info SessionInfo) (*models.AuthSession, error) {
return s, nil
}
// ReuseSession 授权码换令牌时复用登录阶段已经建立的会话。
//
// authorize 阶段已经用 yz_sid Cookie 校验过登录态,换码只是给这个已登录会话
// 签发令牌,不应再 CreateSession:默认「1 号 1 机」策略会把刚登录的会话踢成
// revoked,而浏览器 Cookie 里仍是旧 sid,之后任何一次回到认证中心都会被判定
// 未登录并重新显示登录页(表现为「登录成功后又跳回登录界面」)。
//
// 找不到有效会话(过期、被下线等)时才回退到新建会话。
func ReuseSession(info SessionInfo) (*models.AuthSession, error) {
var list []models.AuthSession
_, err := models.Orm.QueryTable(new(models.AuthSession)).
Filter("identity_id", info.IdentityID).
Filter("tid", info.Tid).
Filter("revoked", 0).
OrderBy("-login_at").
All(&list)
if err == nil {
now := time.Now()
var fallback *models.AuthSession
for i := range list {
if !list[i].ExpiresAt.After(now) {
continue
}
// 优先复用同一应用的登录会话;同一浏览器会话被多个应用共享时,
// client_id 可能为空(直接访问登录页),此时复用最近的有效会话。
if list[i].ClientID == info.ClientID {
_ = TouchSession(list[i].Sid)
return &list[i], nil
}
if fallback == nil {
fallback = &list[i]
}
}
if fallback != nil {
_ = TouchSession(fallback.Sid)
return fallback, nil
}
}
return CreateSession(info)
}
// GetSession 查询有效会话(已吊销或已过期返回错误)
func GetSession(sid string) (*models.AuthSession, error) {
var s models.AuthSession
+118 -3
View File
@@ -202,10 +202,26 @@
.form-foot {
position: absolute;
left: 0; right: 0; bottom: 26px;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
text-align: center;
font-size: 12px;
color: #a3acbf;
}
/* 底部「清除缓存」文字按钮 */
.cache-clear-btn {
border: 0;
background: none;
padding: 0;
font: inherit;
color: var(--primary);
cursor: pointer;
transition: color .2s;
}
.cache-clear-btn:hover { color: var(--primary-dark); text-decoration: underline; }
.cache-clear-btn:disabled { opacity: .6; cursor: default; text-decoration: none; }
.error {
display: none;
@@ -737,7 +753,10 @@
</div>
<div class="form-foot">© 2026 云泽科技 版权所有</div>
<div class="form-foot">
<span>© 2026 云泽科技 版权所有</span>
<button id="cacheClearBtn" class="cache-clear-btn" type="button" onclick="clearSiteCache()" title="仅清除本站在当前浏览器中的缓存数据">清除缓存</button>
</div>
</main>
</div>
@@ -1075,7 +1094,9 @@
return;
}
if (res.redirect) { window.location.href = res.redirect; return; }
window.location.href = '/auth/login';
// 会话已建立但确实没有回跳地址:明确提示,绝不再跳回登录页造成死循环
showError('登录成功,但未获取到回跳地址,请从业务系统重新进入');
setBtnLoading(false);
})
.catch(function () {
showError('网络异常,请稍后重试');
@@ -1094,7 +1115,7 @@
.then(function (res) {
if (res.code !== 200) { showError(res.msg || '切换失败'); return; }
if (res.redirect) { window.location.href = res.redirect; return; }
window.location.href = '/auth/login';
showError('已进入所选企业,但未获取到回跳地址,请从业务系统重新进入');
})
.catch(function () { showError('网络异常,请稍后重试'); });
}
@@ -1105,6 +1126,100 @@
});
}
/**
* 清除本站在当前浏览器里的全部缓存数据。
*
* localStorage / sessionStorage / document.cookie / Cache Storage /
* IndexedDB / Service Worker 都受同源策略约束,只会作用于认证中心自己的
* origin,不会影响 back.yunzer.cn 等其它站点。
* HttpOnly 的 yz_sid 前端读不到,交给 /auth/clear-site-data 的
* Clear-Site-Data 响应头与 Set-Cookie 一起清除。
*/
function clearSiteCache() {
var btn = document.getElementById('cacheClearBtn');
if (btn) { btn.disabled = true; btn.textContent = '清除中...'; }
// 1. Web Storage(记住账号、各类前端状态)
try {
localStorage.clear();
sessionStorage.clear();
} catch (e) { /* 隐私模式下存储可能不可访问 */ }
// 2. 当前站点可写的 Cookie。只针对本站 host:不尝试 .yunzer.cn 等父域,
// 否则会连带清掉 back.yunzer.cn / platform.yunzer.cn 等兄弟站点的数据。
// 两次删除分别覆盖 host-only cookie 与显式 Domain=当前 host 的 cookie。
try {
var cookieNames = (document.cookie || '').split(';').map(function (item) {
return item.split('=')[0].trim();
}).filter(Boolean);
var host = window.location.hostname;
var expired = '=; Max-Age=0; Path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
cookieNames.forEach(function (name) {
document.cookie = name + expired;
document.cookie = name + expired + '; Domain=' + host;
});
} catch (e) { /* ignore */ }
var tasks = [];
// 3. Cache Storage(离线资源 / 静态资源缓存)
if (window.caches && caches.keys) {
tasks.push(caches.keys().then(function (keys) {
return Promise.all((keys || []).map(function (key) { return caches.delete(key); }));
}).catch(function () {}));
}
// 4. Service Worker 注册
if (navigator.serviceWorker && navigator.serviceWorker.getRegistrations) {
tasks.push(navigator.serviceWorker.getRegistrations().then(function (regs) {
return Promise.all((regs || []).map(function (reg) { return reg.unregister(); }));
}).catch(function () {}));
}
// 5. IndexedDB(databases() 在部分浏览器不可用,跳过即可)
if (window.indexedDB && indexedDB.databases) {
tasks.push(indexedDB.databases().then(function (dbs) {
return Promise.all((dbs || []).map(function (db) {
return new Promise(function (resolve) {
if (!db || !db.name) { resolve(); return; }
var done = false;
var finish = function () { if (!done) { done = true; resolve(); } };
var req = indexedDB.deleteDatabase(db.name);
req.onsuccess = req.onerror = req.onblocked = finish;
// 其它标签页占用数据库时 onblocked 可能不触发,兜底避免卡住
setTimeout(finish, 1500);
});
}));
}).catch(function () {}));
}
// 6. 后端清 HttpOnly 会话 Cookie,并让浏览器按 origin 清 HTTP 缓存与存储;
// 服务尚未部署新端点(404)或接口异常时退回已有登出接口,
// 至少保证会话 Cookie 被清除;整体超时也不阻断前面的前端清理结果
var legacyClear = function () {
return fetch('/auth/logout', { method: 'POST', credentials: 'same-origin', cache: 'no-store' })
.catch(function () {});
};
var clearRequest = fetch('/auth/clear-site-data', {
method: 'POST',
credentials: 'same-origin',
cache: 'no-store'
}).then(function (res) {
if (res.ok) return;
return legacyClear();
}).catch(legacyClear);
tasks.push(Promise.race([
clearRequest,
new Promise(function (resolve) { setTimeout(resolve, 5000); })
]));
Promise.all(tasks).then(function () {
// 保留 client_id / redirect,清完直接从登录页重新开始
window.location.replace('/auth/login?client_id=' + encodeURIComponent(CLIENT_ID) +
(REDIRECT ? '&redirect=' + encodeURIComponent(REDIRECT) : ''));
});
}
// ==================== 微信扫码登录(扫码 → 小程序内确认;已接 scan/start、scan/status) ====================
var scanScene = '';
var scanExpireAt = 0;