finance_query_service.go 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547
  1. // 财务只读查询服务(端点 1 / 11 / 12 / 21 的编排层)。
  2. //
  3. // 依据:unified/03-migration/academic-domain-design.md
  4. // - §4.1 端点 1(账户快照)、11(流水列表)、12(工单列表)、21(仪表盘,P2)
  5. // - §3.8-f 约束 2:「`t_user_expense_log` **无 campus 维度 ⇒ 恒等式/汇总只能按 user 做**」
  6. // —— 这条约束直接决定了本服务两个"必须显式拒绝"的边界,见 ListLogs / Dashboard 注释。
  7. // - §1.7 例外一:全局钱包与流水无作用域列,**越权断言必须在 Service 层**(不能靠 Scoped)。
  8. //
  9. // 为什么单独一个查询服务(而不是塞进 handler 或 FundService):
  10. // - Handler 层纪律是"不碰 DB"(infra/scope 包注释、org/identity handler 同款);
  11. // - FundService 是**资金写路径**(每个方法都在事务里改余额),把 4 个只读聚合塞进去会
  12. // 让"这个服务会不会动钱"变得不可一眼判断(评审成本最高的一类退化)。
  13. // 本服务**全程只读、不开事务**(唯一例外是它调用的仓储方法本身不开事务)。
  14. package service
  15. import (
  16. "context"
  17. "strings"
  18. "time"
  19. "gorm.io/gorm"
  20. "yuansiwei/unified-backend/internal/academic/model"
  21. "yuansiwei/unified-backend/internal/academic/money"
  22. "yuansiwei/unified-backend/internal/academic/port"
  23. "yuansiwei/unified-backend/internal/academic/repository"
  24. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  25. appscope "yuansiwei/unified-backend/internal/infra/scope"
  26. )
  27. // FinanceQueryService 财务只读查询服务(端点 1 / 11 / 12 / 21)。
  28. //
  29. // 字段:
  30. // - expense 余额账户仓储(全局钱包 / 校区账户 / 流水);
  31. // - requests 余额工单仓储(工单列表);
  32. // - member 成员校验器(可 nil:nil 时跳过对象级越权断言,仅限双跑/回填脚本);
  33. // - sc 请求作用域(可 nil:系统任务视角)。
  34. type FinanceQueryService struct {
  35. expense *repository.ExpenseRepo
  36. requests *repository.BalanceRequestRepo
  37. member port.MembershipChecker
  38. sc *appscope.Scope
  39. }
  40. // NewFinanceQueryService 构造财务只读查询服务。
  41. //
  42. // 入参:db *gorm.DB(必填);sc *appscope.Scope(请求作用域);member port.MembershipChecker(可 nil)。
  43. // 出参:*FinanceQueryService。
  44. func NewFinanceQueryService(db *gorm.DB, sc *appscope.Scope, member port.MembershipChecker) *FinanceQueryService {
  45. return &FinanceQueryService{
  46. expense: repository.NewExpenseRepo(db, sc),
  47. requests: repository.NewBalanceRequestRepo(db, sc),
  48. member: member,
  49. sc: sc,
  50. }
  51. }
  52. // ---------------------------------------------------------------- 端点 1:账户快照
  53. // AccountSnapshot 账户快照(端点 1 `GET /admin/finance/accounts/:userId`)。
  54. //
  55. // **形状:扁平**(契约 v1.5 裁决 —— 前端页面已按扁平读,改后端只动 DTO 与断言,改前端要动页面)。
  56. // 每字段的来源列(契约 §3.4 ③「字段准入条件」要求可追溯):
  57. //
  58. // totalBalance ← t_user_expense.balance (全局/跨校区)
  59. // campusBalance ← t_user_expense_school.balance (**指定校区**;未指定校区或无账户 → "0.00")
  60. // specialExpenses ← 对应表的 special_expenses (有校区账户取校区值,否则取全局值)
  61. // cumulativeIncome ← total_income (同上取法)
  62. //
  63. // `institutionId` / `campusId` 是**入参回显**,不是账户列
  64. // (`t_user_expense` 根本没有作用域列,§3.8-f 约束 2)⇒ **不得**从账户行里取。
  65. type AccountSnapshot struct {
  66. // UserID 用户(ULID)。
  67. UserID string `json:"userId"`
  68. // InstitutionID 机构 ID(**入参回显**,不是账户列)。
  69. InstitutionID int64 `json:"institutionId"`
  70. // CampusID 校区 ID(**入参回显**,不是账户列)。
  71. CampusID int64 `json:"campusId"`
  72. // TotalBalance 全局(跨校区)余额(元)← `t_user_expense.balance`。
  73. TotalBalance money.Amount `json:"totalBalance"`
  74. // CampusBalance 指定校区余额(元)← `t_user_expense_school.balance`(未指定/无账户 → 0)。
  75. CampusBalance money.Amount `json:"campusBalance"`
  76. // SpecialExpenses 特殊费用(元):有校区账户取校区列,否则取全局列。
  77. SpecialExpenses money.Amount `json:"specialExpenses"`
  78. // CumulativeIncome 累计充值(元)← `total_income`(同上取法)。
  79. CumulativeIncome money.Amount `json:"cumulativeIncome"`
  80. }
  81. // AccountSnapshot 读账户快照(端点 1)。
  82. //
  83. // 入参:
  84. // - ctx 请求上下文;
  85. // - userID 用户(ULID;必填);
  86. // - institutionID 机构 ID(可选:>0 时作为越权判定输入与校区账户定位维度);
  87. // - campusID 校区 ID(可选:>0 时附带该校区账户;=0 则不返回 campus 段)。
  88. //
  89. // 出参:
  90. // - *AccountSnapshot;全局账户不存在时 Global 全 0 且 **不报错**(新用户尚未建户,
  91. // 与 dump 的"账户不存在 → 余额 0"同口径;前端按 0 展示);
  92. // - ErrBadParam(2001) 缺 user_id / ErrUnscoped(1004) 越权 / ErrDBError(5002)。
  93. //
  94. // 越权:全局钱包无作用域列(§1.7 例外一)⇒ **必须**在这里经 member 断言该用户属于本机构;
  95. // 未注入 member(双跑脚本)时跳过——这条跳过的后果写进注释,评审可一眼看到边界。
  96. func (s *FinanceQueryService) AccountSnapshot(
  97. ctx context.Context, userID string, institutionID, campusID int64,
  98. ) (*AccountSnapshot, error) {
  99. if strings.TrimSpace(userID) == "" {
  100. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: AccountSnapshot 需要 user_id"))
  101. }
  102. if err := s.assertMember(ctx, userID, institutionID, campusID); err != nil {
  103. return nil, err
  104. }
  105. out := &AccountSnapshot{UserID: userID}
  106. // 入参回显(**不是**账户列:`t_user_expense` 无作用域列)
  107. instID := institutionID
  108. if instID <= 0 && s.sc != nil {
  109. instID = s.sc.InstitutionID
  110. }
  111. out.InstitutionID = instID
  112. out.CampusID = campusID
  113. global, err := s.expense.GetGlobalAccount(ctx, nil, userID)
  114. if err != nil {
  115. if isNotFoundErr(err) {
  116. // 账户不存在:全 0(dump 的 ISNULL 归零分支),不是错误
  117. return out, nil
  118. }
  119. return nil, err
  120. }
  121. // totalBalance ← t_user_expense.balance
  122. globalBalance, berr := parseAccountAmount(global.Balance, "t_user_expense.balance")
  123. if berr != nil {
  124. return nil, berr
  125. }
  126. out.TotalBalance = globalBalance
  127. // specialExpenses / cumulativeIncome 默认取**全局**列,有校区账户时被校区值覆盖
  128. globalSpecial, serr := parseAccountAmount(global.SpecialExpenses, "t_user_expense.special_expenses")
  129. if serr != nil {
  130. return nil, serr
  131. }
  132. globalIncome, ierr := parseAccountAmount(global.TotalIncome, "t_user_expense.total_income")
  133. if ierr != nil {
  134. return nil, ierr
  135. }
  136. out.SpecialExpenses = globalSpecial
  137. out.CumulativeIncome = globalIncome
  138. if campusID <= 0 {
  139. return out, nil
  140. }
  141. if instID <= 0 {
  142. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  143. errString("service: 指定 campus_id 时还需要 institution_id(或作用域机构)"))
  144. }
  145. campus, cErr := s.expense.GetCampusAccount(ctx, nil, userID, instID, campusID)
  146. if cErr != nil {
  147. if isNotFoundErr(cErr) {
  148. // 该校区未建户 ⇒ campusBalance 保持 "0.00"(扁平形状下无法用 null 表达"未建户",
  149. // 这是扁平化的**已知代价**:前端要区分"未建户"与"余额 0"需另加标志位,本期不加)
  150. return out, nil
  151. }
  152. return nil, cErr
  153. }
  154. // campusBalance ← t_user_expense_school.balance;special/income 也改取校区列(校区视图口径)
  155. campusBalance, cberr := parseAccountAmount(campus.Balance, "t_user_expense_school.balance")
  156. if cberr != nil {
  157. return nil, cberr
  158. }
  159. out.CampusBalance = campusBalance
  160. campusSpecial, cserr := parseAccountAmount(campus.SpecialExpenses, "t_user_expense_school.special_expenses")
  161. if cserr != nil {
  162. return nil, cserr
  163. }
  164. campusIncome, cierr := parseAccountAmount(campus.TotalIncome, "t_user_expense_school.total_income")
  165. if cierr != nil {
  166. return nil, cierr
  167. }
  168. out.SpecialExpenses = campusSpecial
  169. out.CumulativeIncome = campusIncome
  170. return out, nil
  171. }
  172. // ---------------------------------------------------------------- 端点 11:流水列表
  173. // ListLogsInput 流水列表入参(端点 11 `GET /admin/finance/logs`)。
  174. //
  175. // 字段:
  176. // - UserID 用户(**非超管必填**,见 ListLogs 注释);
  177. // - Type 流水类型(nil 不筛);
  178. // - From / To `create_time` 闭区间(nil 不筛);
  179. // - Page / PageSize 分页(<=0 → ClampPage 归一;>200 → 200)。
  180. type ListLogsInput struct {
  181. // UserID 用户(ULID;非超管必填)。
  182. UserID string
  183. // Type 流水类型(model.ExpenseLogType*;nil 不筛)。
  184. Type *int32
  185. // From 起始时间(闭区间;nil 不筛)。
  186. From *time.Time
  187. // To 结束时间(闭区间;nil 不筛)。
  188. To *time.Time
  189. // Page 页码。
  190. Page int
  191. // PageSize 每页条数。
  192. PageSize int
  193. }
  194. // LogView 流水分页行(端点 11 响应 list 元素)。
  195. //
  196. // 安全:只暴露对账必需列;`remark` 之外的自由文本(如 `t_order_pay.pay_return_data`、
  197. // 工单 `imgs`)**不在本视图内**(AC-12 验收第 ⑥ 条)。
  198. type LogView struct {
  199. // ID 流水 ID。
  200. ID int64 `json:"id"`
  201. // UserID 用户(ULID)。
  202. UserID string `json:"userId"`
  203. // Type 流水类型(model.ExpenseLogType*)。
  204. Type int32 `json:"type"`
  205. // LogID 业务单号(旧 logid;可空 → "")。
  206. LogID string `json:"logId"`
  207. // Money 变更金额(元;符号表方向)。
  208. Money money.Amount `json:"money"`
  209. // BalanceAfter 该操作完成后**全局账户**余额快照(历史行 NULL → null)。
  210. BalanceAfter *money.Amount `json:"balanceAfter"`
  211. // Remark 备注(脱敏后透出;写入侧已禁止敏感信息)。
  212. Remark string `json:"remark"`
  213. // CreateTime 业务时间(§3.8-E2 ①类)。
  214. CreateTime time.Time `json:"createTime"`
  215. }
  216. // ListLogs 分页查流水(端点 11)。
  217. //
  218. // 入参:ctx;in ListLogsInput。
  219. // 出参:([]LogView, total, error);错误码 2001(缺 userId 且非超管)/1004/5002。
  220. //
  221. // **作用域红线(必须读)**:`t_user_expense_log` **没有** institution_id / campus_id
  222. // (§3.8-f 约束 2),因此"跨用户查全机构流水"在数据层**无法**按机构过滤——一旦放开,
  223. // 普通财务就能读到**别的机构**用户的流水(真实越权)。
  224. // 处置:**非超管必须带 userId**(否则 2001),并由 member 断言该用户属于本机构;
  225. // 超管可跨用户(但仍**不**跨库过滤,需要机构维度请按 userId 逐用户查)。
  226. // 这不是"少做一步",而是把"做不到"显式化(2001 + 明确文案),绝不静默返回全量。
  227. func (s *FinanceQueryService) ListLogs(ctx context.Context, in ListLogsInput) ([]LogView, int64, error) {
  228. userID := strings.TrimSpace(in.UserID)
  229. isSuper := s.sc != nil && s.sc.SuperAdmin
  230. if userID == "" {
  231. if !isSuper {
  232. return nil, 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  233. errString("service: ListLogs 非超管必须指定 userId(流水表无机构列,无法按机构过滤,禁止全量导出)"))
  234. }
  235. } else if err := s.assertMember(ctx, userID, 0, 0); err != nil {
  236. return nil, 0, err
  237. }
  238. if in.From != nil && in.To != nil && in.From.After(*in.To) {
  239. return nil, 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  240. errString("service: ListLogs 的 from 不能晚于 to"))
  241. }
  242. page := repository.PageQuery{Page: in.Page, PageSize: in.PageSize}
  243. rows, total, err := s.expense.ListLogsFiltered(ctx, repository.LogFilter{
  244. UserID: userID, Type: in.Type, From: in.From, To: in.To,
  245. }, page)
  246. if err != nil {
  247. return nil, 0, err
  248. }
  249. views := make([]LogView, 0, len(rows))
  250. for i := range rows {
  251. view, vErr := buildLogView(&rows[i])
  252. if vErr != nil {
  253. return nil, 0, vErr
  254. }
  255. views = append(views, *view)
  256. }
  257. return views, total, nil
  258. }
  259. // buildLogView 把流水行转成视图(金额定点解析 + 空值归一)。
  260. //
  261. // 入参:row 流水行(非 nil)。出参:*LogView;金额非法 → ErrDBError。
  262. func buildLogView(row *model.UserExpenseLog) (*LogView, error) {
  263. amount, err := money.Parse(row.Money)
  264. if err != nil {
  265. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  266. }
  267. view := &LogView{
  268. ID: row.ID, UserID: row.UserID, Type: row.Type, Money: amount, CreateTime: row.CreateTime,
  269. }
  270. if row.LogID != nil {
  271. view.LogID = *row.LogID
  272. }
  273. if row.Remark != nil {
  274. view.Remark = *row.Remark
  275. }
  276. if row.BalanceAfter != nil {
  277. after, perr := money.Parse(*row.BalanceAfter)
  278. if perr != nil {
  279. return nil, infraerrors.WithCause(infraerrors.ErrDBError, perr)
  280. }
  281. view.BalanceAfter = &after
  282. }
  283. return view, nil
  284. }
  285. // ---------------------------------------------------------------- 端点 12:工单列表
  286. // ListRequestsInput 工单列表入参(端点 12 `GET /admin/finance/requests`)。
  287. type ListRequestsInput struct {
  288. // Type 工单类型(100/101/202/203/300;nil 不筛)。
  289. Type *int32
  290. // Approved 审批状态(-2/-1/0/1;nil 不筛)。
  291. Approved *int8
  292. // UserID 用户(空 = 本机构全部)。
  293. UserID string
  294. // Page / PageSize 分页。
  295. Page int
  296. PageSize int
  297. }
  298. // RequestView 工单列表行(端点 12 响应 list 元素)。
  299. //
  300. // 安全:**不含 `imgs`**(凭证图片可能含个人信息/银行流水,model 侧已 `json:"-"`,
  301. // 这里同样不纳入视图);如需下载凭证,走单独的受控端点(本期不做)。
  302. type RequestView struct {
  303. // ID 工单 ID。
  304. ID int64 `json:"id"`
  305. // BizNo 业务单号(对外单号;幂等锚点)。
  306. BizNo string `json:"bizNo"`
  307. // Type 工单类型。
  308. Type int32 `json:"type"`
  309. // UserID 用户。
  310. UserID string `json:"userId"`
  311. // InstitutionID 机构 ID。
  312. InstitutionID int64 `json:"institutionId"`
  313. // CampusID 校区 ID。
  314. CampusID int64 `json:"campusId"`
  315. // ToInstitutionID 转入机构(仅 type=300)。
  316. ToInstitutionID *int64 `json:"toInstitutionId"`
  317. // ToCampusID 转入校区(仅 type=300)。
  318. ToCampusID *int64 `json:"toCampusId"`
  319. // Money 金额(元)。
  320. Money money.Amount `json:"money"`
  321. // TransferAccountsType 收款方式(0/1/2/3)。
  322. TransferAccountsType int8 `json:"transferAccountsType"`
  323. // OrderNo 交易单号(可空)。
  324. OrderNo string `json:"orderNo"`
  325. // Item 项目(可空)。
  326. Item string `json:"item"`
  327. // Remark 备注(可空)。
  328. Remark string `json:"remark"`
  329. // ApplicantID 申请人(← `request_user_id`;契约名 `applicantId`,语义听 dump:`request_user_id`)。
  330. ApplicantID string `json:"applicantId"`
  331. // RequestTime 申请时间(← **`request_time`**,业务时间;可空)。
  332. RequestTime *time.Time `json:"requestTime"`
  333. // ApproverID 审批人(← `response_user_id`;**未审批时为 null**,契约名 `approverId`)。
  334. ApproverID *string `json:"approverId"`
  335. // ResponseTime 审批时间(← **`response_time`**,业务时间;未审批为 null)。
  336. ResponseTime *time.Time `json:"responseTime"`
  337. // ResponseMsg 审批意见(可空)。
  338. ResponseMsg string `json:"responseMsg"`
  339. // Approved 审批状态(-2/-1/0/1)。
  340. Approved int8 `json:"approved"`
  341. // ⚠ **不下发 `createdAt`**(契约 v1.5 裁决):
  342. // `created_at` 是**迁移期新加的入库时间列**(`DEFAULT CURRENT_TIMESTAMP(3)`),
  343. // 迁移后**所有历史工单的值都是"迁移执行那一刻"**——下发会让前端把全部历史工单排在同一天。
  344. // 它不是业务时间,**任何端点都不得当业务时间下发**(要业务时间用 `requestTime`)。
  345. }
  346. // ListRequests 分页查工单(端点 12)。
  347. //
  348. // 入参:ctx;in ListRequestsInput。出参:([]RequestView, total, error);2001/5002。
  349. //
  350. // 作用域:仓储经 `Scoped`(普通角色只见本机构;超管全量)——工单表**有** institution_id,
  351. // 所以这里不需要 ListLogs 那种"非超管必须带 userId"的兜底。
  352. func (s *FinanceQueryService) ListRequests(ctx context.Context, in ListRequestsInput) ([]RequestView, int64, error) {
  353. page := repository.PageQuery{Page: in.Page, PageSize: in.PageSize}
  354. rows, total, err := s.requests.ListFiltered(ctx, repository.RequestFilter{
  355. Type: in.Type, Approved: in.Approved, UserID: strings.TrimSpace(in.UserID),
  356. }, page)
  357. if err != nil {
  358. return nil, 0, err
  359. }
  360. views := make([]RequestView, 0, len(rows))
  361. for i := range rows {
  362. view, vErr := buildRequestView(&rows[i])
  363. if vErr != nil {
  364. return nil, 0, vErr
  365. }
  366. views = append(views, *view)
  367. }
  368. return views, total, nil
  369. }
  370. // buildRequestView 把工单行转成视图(金额定点解析 + 可空列归一)。
  371. //
  372. // 入参:row 工单行(非 nil)。出参:*RequestView;金额非法 → ErrDBError。
  373. func buildRequestView(row *model.UserBalanceRequest) (*RequestView, error) {
  374. amount, err := money.Parse(row.Money)
  375. if err != nil {
  376. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  377. }
  378. view := &RequestView{
  379. ID: row.ID, BizNo: row.BizNo, Type: row.Type, UserID: row.UserID,
  380. InstitutionID: row.InstitutionID, CampusID: row.CampusID,
  381. ToInstitutionID: row.ToInstitutionID, ToCampusID: row.ToCampusID,
  382. Money: amount, TransferAccountsType: row.TransferAccountsType,
  383. ApplicantID: row.RequestUserID, ApproverID: row.ResponseUserID,
  384. RequestTime: row.RequestTime, ResponseTime: row.ResponseTime, Approved: row.Approved,
  385. }
  386. if row.OrderNo != nil {
  387. view.OrderNo = *row.OrderNo
  388. }
  389. if row.Item != nil {
  390. view.Item = *row.Item
  391. }
  392. if row.Remark != nil {
  393. view.Remark = *row.Remark
  394. }
  395. // approverId ← response_user_id(未审批 → null,用指针保留"未审批"语义)
  396. view.ApproverID = row.ResponseUserID
  397. if row.ResponseMsg != nil {
  398. view.ResponseMsg = *row.ResponseMsg
  399. }
  400. return view, nil
  401. }
  402. // ---------------------------------------------------------------- 端点 21:仪表盘(P2)
  403. // DashboardInput 仪表盘入参(端点 21 `GET /admin/finance/dashboard`)。
  404. type DashboardInput struct {
  405. // From / To 统计窗口回显(本端点按"账户累计列"聚合,窗口仅回显 + 供前端对齐筛选器,见 Dashboard 注释)。
  406. From time.Time
  407. To time.Time
  408. // CampusID 校区 ID(>0 只统计该校区;0 = 全机构)。
  409. CampusID int64
  410. }
  411. // DashboardResult 仪表盘结果(余额分布 + 累计收支)。
  412. //
  413. // ⚠ 口径(P2 简化,已在设计 §4.1 #21「P2,可延后」范围内):
  414. // `t_user_expense_log` 无 campus 列 ⇒ **无法**按校区/机构聚合"区间发生额"(§3.8-f 约束 2)。
  415. // 因此本结果是**账户累计列**的横截面(total_income / total_expenditure / balance / special_expenses),
  416. // **不是** [From, To] 区间增量。`from`/`to` 仅回显,供前端呈现筛选器状态;
  417. // 若产品确需"区间发生额分布",须先给出"流水 → 机构/校区"的归属路径(当前 schema 不支持),
  418. // 属跨域建模变更,不在本期。
  419. type DashboardResult struct {
  420. // From / To 回显窗口。
  421. From time.Time `json:"from"`
  422. To time.Time `json:"to"`
  423. // CampusID 统计维度(0 = 全机构)。
  424. CampusID int64 `json:"campusId"`
  425. // Accounts 参与统计的校区账户数。
  426. Accounts int64 `json:"accounts"`
  427. // Balance 校区余额合计(元)。
  428. Balance money.Amount `json:"balance"`
  429. // TotalIncome 累计充值合计(元)。
  430. TotalIncome money.Amount `json:"totalIncome"`
  431. // TotalExpenditure 累计消费合计(元)。
  432. TotalExpenditure money.Amount `json:"totalExpenditure"`
  433. // SpecialExpenses 特殊费用合计(元)。
  434. SpecialExpenses money.Amount `json:"specialExpenses"`
  435. // NetReceivable 应收净额 = TotalIncome − TotalExpenditure(元;**派生列**,便于看板直接展示)。
  436. NetReceivable money.Amount `json:"netReceivable"`
  437. // BalanceDistribution 各校区余额分布(契约 v1.5 唯一可算项):
  438. // ← `t_user_expense_school.balance` 按 `campus_id` 聚合(该表**有** schoolid/campusid)。
  439. // 空 → `[]`。
  440. BalanceDistribution []CampusBalanceItem `json:"balanceDistribution"`
  441. // FlowsByCampusSupported 恒 `false`:明确告知前端"本端点不支持按区间/校区拆分流水",
  442. // 免得前端以为字段缺失是自己拼错(口径见上方类型注释)。
  443. FlowsByCampusSupported bool `json:"flowsByCampusSupported"`
  444. }
  445. // CampusBalanceItem 单校区余额(端点 21 的 `balanceDistribution` 元素)。
  446. type CampusBalanceItem struct {
  447. // CampusID 校区 ID(← `t_user_expense_school.campus_id`;0 = 机构级账户)。
  448. CampusID int64 `json:"campusId"`
  449. // Balance 余额(元)← `t_user_expense_school.balance`。
  450. Balance money.Amount `json:"balance"`
  451. }
  452. // Dashboard 财务仪表盘(端点 21,P2)。
  453. //
  454. // 入参:ctx;in DashboardInput(From/To 必填;CampusID 可选)。
  455. // 出参:*DashboardResult;2001(缺时间窗)/1004/5002。
  456. func (s *FinanceQueryService) Dashboard(ctx context.Context, in DashboardInput) (*DashboardResult, error) {
  457. if in.From.IsZero() || in.To.IsZero() {
  458. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  459. errString("service: Dashboard 需要 from/to"))
  460. }
  461. if in.From.After(in.To) {
  462. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  463. errString("service: Dashboard 的 from 不能晚于 to"))
  464. }
  465. if s.sc == nil {
  466. return nil, infraerrors.WithCause(infraerrors.ErrUnauth,
  467. errString("service: Dashboard 需要请求作用域(机构维度不可缺省)"))
  468. }
  469. agg, err := s.expense.AggregateCampusAccounts(ctx, s.sc.InstitutionID, in.CampusID)
  470. if err != nil {
  471. return nil, err
  472. }
  473. rows, derr := s.expense.ListCampusBalances(ctx, s.sc.InstitutionID, in.CampusID)
  474. if derr != nil {
  475. return nil, derr
  476. }
  477. dist := make([]CampusBalanceItem, 0, len(rows))
  478. for i := range rows {
  479. dist = append(dist, CampusBalanceItem{CampusID: rows[i].CampusID, Balance: rows[i].Balance})
  480. }
  481. return &DashboardResult{
  482. From: in.From, To: in.To, CampusID: in.CampusID, Accounts: agg.Accounts,
  483. BalanceDistribution: dist,
  484. Balance: agg.Balance, TotalIncome: agg.TotalIncome,
  485. TotalExpenditure: agg.TotalExpenditure, SpecialExpenses: agg.SpecialExpenses,
  486. NetReceivable: agg.TotalIncome.Sub(agg.TotalExpenditure),
  487. FlowsByCampusSupported: false,
  488. }, nil
  489. }
  490. // ---------------------------------------------------------------- 共享断言
  491. // assertMember 对象级越权断言(全局钱包/流水无作用域列,必须在这里断)。
  492. //
  493. // 入参:ctx;userID 目标用户;institutionID/campusID 作用域维度(0 = 不限/取作用域)。
  494. // 出参:nil 通过;ErrUnscoped(1004) 越界;ErrDBError。
  495. //
  496. // 三种放行情形(顺序即优先级):
  497. // 1. member 未注入(双跑脚本/系统任务)→ 跳过(**这是唯一的"不断言"入口**,评审可见);
  498. // 2. 作用域为超管 → 跳过;
  499. // 3. 其余 → `member.AssertInScope(userID, 机构, 校区)`。
  500. // 机构/校区为 0 时由实现方按"机构内任一校区"语义判定(port 契约已写明)。
  501. func (s *FinanceQueryService) assertMember(
  502. ctx context.Context, userID string, institutionID, campusID int64,
  503. ) error {
  504. if s.member == nil {
  505. return nil
  506. }
  507. if s.sc != nil && s.sc.SuperAdmin {
  508. return nil
  509. }
  510. instID, campID := institutionID, campusID
  511. if instID <= 0 && s.sc != nil {
  512. instID = s.sc.InstitutionID
  513. }
  514. if campID <= 0 && s.sc != nil {
  515. campID = s.sc.CampusID
  516. }
  517. return s.member.AssertInScope(ctx, userID, instID, campID)
  518. }