expenditure_handler.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. // 收支端点处理器(端点 60:3 条路由)——AC-13d 第三批。
  2. //
  3. // 依据:unified/03-migration/academic-frontend-contract-v1.md §2.5 行 60(角色 `super_admin, finance`)/
  4. // §3.4 D 表 60(v1.9 重写:JOIN 字典、补 `expenditureId`、删 `direction`、`userId`→`createUserId`);
  5. // 设计 `academic-domain-design.md` §4.5(端点清单)。
  6. //
  7. // ---------------------------------------------------------------------------
  8. // 端点 → 服务方法对照(3 条,**没有 `POST /types`**)
  9. // ---------------------------------------------------------------------------
  10. //
  11. // 60a GET /admin/expenditures ListExpenditures → ExpenditureService.ListLogs
  12. // 60b POST /admin/expenditures CreateExpenditure → ExpenditureService.CreateLog
  13. // 60c GET /admin/expenditures/types ListExpenditureTypes → ExpenditureService.ListTypes
  14. //
  15. // 契约原写法 `GET/POST /admin/expenditures[/types]` 字面像 4 条,实为 3 条(v1.9 明确不做 `POST /types`)。
  16. // `POST /types` 若将来要加,**必须先解决"平台通用字典能否由机构改"**——让任一机构新建类型并不难,
  17. // 难的是删/改时的引用完整性(`expenditure_log.expenditure_id` 指向它,删一条字典会让历史记录失去类型名)。
  18. //
  19. // ---------------------------------------------------------------------------
  20. // 静态段 vs `:id` 段(本批**无真冲突**,如实说明)
  21. // ---------------------------------------------------------------------------
  22. //
  23. // `/admin/expenditures/types` 与未来可能的 `/admin/expenditures/:id` 属**同 depth**,
  24. // 但本批**没有** `:id` 路由 ⇒ 当前不存在"谁先注册"的问题。仍把 `types` 排在前面注册:
  25. // 将来若加 `GET /:id`,只要保持"静态段在 `:id` 之前"这条纪律(route.go 表顺序即注册顺序),
  26. // 就不会出现 `:id` 吃掉 "types"(解析失败 → 2002)这种"改一行、整页报错"的故障。
  27. // **不编造一条假冲突来凑"变红用例"**(AC-13c 已按此口径如实登记过)。
  28. //
  29. // ---------------------------------------------------------------------------
  30. // 三条纪律
  31. // ---------------------------------------------------------------------------
  32. //
  33. // 1. **Handler 不碰 DB**:只做"取作用域 → 解析参数 → 调 Service → 写响应"(本文件无一条 SQL)。
  34. // 2. **金额一律走 `money.Amount`**:入参 `money` 是**字符串**(`parseMoney`),出参也序列化成字符串
  35. // (2 位小数)。**任何一处出现 float64**,对账迟早差几分钱,而且查不出来。
  36. // 3. **机构/校区越权统一 1004**:查询侧由 Service `assertInstitution` 报,
  37. // 写入侧由 `assertScopeMatch` 报;SQL 层的 `Scoped` 是第二道,不替代 1004(空列表 ≠ 越权)。
  38. package handler
  39. import (
  40. "strings"
  41. "time"
  42. "github.com/gofiber/fiber/v2"
  43. "yuansiwei/unified-backend/internal/academic/service"
  44. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  45. "yuansiwei/unified-backend/internal/infra/response"
  46. appscope "yuansiwei/unified-backend/internal/infra/scope"
  47. )
  48. // ---------------------------------------------------------------- 路径常量(**不含 `/api`**)
  49. const (
  50. // PathExpenditures 端点 60:收支记录列表 / 记账(`/admin/expenditures`)。
  51. PathExpenditures = "/admin/expenditures"
  52. // PathExpenditureTypes 端点 60:收支类型字典(`/admin/expenditures/types`;**静态段先于 `:id`**)。
  53. PathExpenditureTypes = "/admin/expenditures/types"
  54. )
  55. // ListExpenditureLogs 收支记录列表(端点 60 `GET /admin/expenditures`)。
  56. //
  57. // 入参(query):`institutionId?`(超管可借此收窄;传了则非超管必须等于作用域机构,否则 1004)、
  58. // `campusId?`、`expenditureId?`、`type?`(1 支出 / 2 收入)、`from?`/`to?`、分页。
  59. //
  60. // `from`/`to` 口径(**两句话,别记反**):
  61. //
  62. // `from` 恒为**闭**下界(`>=`);
  63. // `to` 恒为**开**上界(`<`)——但纯日期形态会自动补到次日 00:00,
  64. // 所以 `to=2026-04-10` 的实际含义是"**含 4 月 10 日一整天**"。
  65. //
  66. // 为什么不做成"闭区间":`create_time` 是 `datetime(3)`,`<= 2026-04-10 00:00:00`
  67. // 会把 4 月 10 日白天记的账全部漏掉(用户以为"今天没记账")。
  68. // 为什么保留两种形态:前端"日期选择器"只给 `2006-01-02`,而报表导出会带时分秒;
  69. // 若一律当"当天零点",后者的语义会静默变化。
  70. // 出参(成功):`{list,total,page,pageSize,pages}`,行见 `expenditureLogView`(9 字段,含字典 `type`/`name`)。
  71. // 出参(失败):1001/2001/2002(`type`/`campusId`/时间格式非法)/**1004**(跨机构/跨校区)/5002。
  72. //
  73. // ⚠ **`type` 与 `name` 是字典列**(记录表没有):服务层按 `expenditure_id` 批量查字典补齐,
  74. // 且**包含平台通用字典 `institution_id = 0`**(那不是未归属数据,是"房租/水电/学费收入"这类全平台类型)。
  75. func (h *Handler) ListExpenditureLogs(c *fiber.Ctx) error {
  76. sc, err := mustScope(c)
  77. if err != nil {
  78. return response.Fail(c, err)
  79. }
  80. institutionID, err := optionalInstitutionQuery(c, sc)
  81. if err != nil {
  82. return response.Fail(c, err)
  83. }
  84. campusID, err := parseIntQuery(c, "campusId")
  85. if err != nil {
  86. return response.Fail(c, err)
  87. }
  88. if campusID < 0 {
  89. return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat,
  90. errString("campusId 不得为负")))
  91. }
  92. var campusPtr *int64
  93. if strings.TrimSpace(c.Query("campusId")) != "" {
  94. // 校区维度越权:非超管给了别的校区 = 1004(不是"空列表")
  95. if serr := assertScopeMatches(sc, effectiveInstitution(sc, institutionID), campusID, "收支记录列表"); serr != nil {
  96. return response.Fail(c, serr)
  97. }
  98. v := campusID
  99. campusPtr = &v
  100. }
  101. expenditureID, err := parseIntQuery(c, "expenditureId")
  102. if err != nil {
  103. return response.Fail(c, err)
  104. }
  105. if expenditureID < 0 {
  106. return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat,
  107. errString("expenditureId 不得为负")))
  108. }
  109. var expenditurePtr *int64
  110. if strings.TrimSpace(c.Query("expenditureId")) != "" {
  111. v := expenditureID
  112. expenditurePtr = &v
  113. }
  114. direction, _, err := parseEnumQuery(c, "type", map[int64]struct{}{
  115. int64(service.ExpenditureDirectionExpense): {},
  116. int64(service.ExpenditureDirectionIncome): {},
  117. })
  118. if err != nil {
  119. return response.Fail(c, err)
  120. }
  121. var directionPtr *int32
  122. if direction != nil {
  123. v := int32(*direction)
  124. directionPtr = &v
  125. }
  126. from, hasFrom, err := parseTimeParam(c.Query("from"), "from")
  127. if err != nil {
  128. return response.Fail(c, err)
  129. }
  130. to, hasTo, err := parseTimeParam(c.Query("to"), "to")
  131. if err != nil {
  132. return response.Fail(c, err)
  133. }
  134. if hasTo {
  135. to = normalizeToUpperBound(c.Query("to"), to)
  136. }
  137. if hasFrom && hasTo && to.Before(from) {
  138. return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat,
  139. errString("to 不能早于 from")))
  140. }
  141. page, pageSize := parsePage(c)
  142. svc := h.servicesFor(sc)
  143. rows, total, err := svc.expenditure.ListLogs(c.Context(), service.ExpenditureLogQuery{
  144. InstitutionID: institutionID,
  145. CampusID: campusPtr,
  146. ExpenditureID: expenditurePtr,
  147. Type: directionPtr,
  148. From: timePtr(from, hasFrom),
  149. To: timePtr(to, hasTo),
  150. Page: page,
  151. PageSize: pageSize,
  152. })
  153. if err != nil {
  154. return response.Fail(c, err)
  155. }
  156. views := make([]expenditureLogView, 0, len(rows))
  157. for i := range rows {
  158. row := &rows[i]
  159. views = append(views, expenditureLogView{
  160. ID: row.ID,
  161. BizNo: row.BizNo,
  162. ExpenditureID: row.ExpenditureID,
  163. Type: row.Type,
  164. Name: row.Name,
  165. Money: row.Money.String(), // 恒 2 位小数(定点,无 float 路径)
  166. CreateUserID: row.CreateUserID, // 可空指针:NULL → null(不是 "")
  167. CampusID: row.CampusID,
  168. CreateTime: row.CreateTime,
  169. })
  170. }
  171. return response.Page(c, views, page, pageSize, total)
  172. }
  173. // CreateExpenditureLog 记一笔收支(端点 60 `POST /admin/expenditures`)。
  174. //
  175. // 入参(body):见 `CreateExpenditureLogReq`(`expenditureId` + `money` 必填)。
  176. // 出参(成功):`{id, bizNo}`。
  177. // 出参(失败):1001/2001(缺机构/缺类型/缺金额)/2002(金额 ≤0 或格式非法、校区为负)/
  178. // **3001**(收支类型不存在或对本机构不可见)/1004(跨机构/跨校区)/5002。
  179. //
  180. // 事务:字典可见性校验 + 插记录 + 回写报表分桶(`year`/`month`)**同一事务**。
  181. func (h *Handler) CreateExpenditureLog(c *fiber.Ctx) error {
  182. sc, err := mustScope(c)
  183. if err != nil {
  184. return response.Fail(c, err)
  185. }
  186. var req CreateExpenditureLogReq
  187. if !validateReq(c, &req) {
  188. return nil // body code=2001 已写出
  189. }
  190. institutionID, err := resolveInstitutionID(sc, req.InstitutionID)
  191. if err != nil {
  192. return response.Fail(c, err) // 2001 缺机构 / 1004 跨机构
  193. }
  194. if req.CampusID < 0 {
  195. return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat,
  196. errString("campusId 不得为负")))
  197. }
  198. campusID := req.CampusID
  199. if campusID == 0 && sc.CampusID > 0 {
  200. // 未传校区且有校区作用域 → 取作用域校区。**只会收窄,不会放宽**:
  201. // 反过来"补 0"会让一条本该记在某校区的账落到机构级,校区报表直接少一条。
  202. campusID = sc.CampusID
  203. }
  204. if serr := assertScopeMatches(sc, institutionID, campusID, "记账"); serr != nil {
  205. return response.Fail(c, serr)
  206. }
  207. amount, ok := parseMoney(c, req.Money, "money")
  208. if !ok {
  209. return nil // body code=2002 已写出
  210. }
  211. svc := h.servicesFor(sc)
  212. created, err := svc.expenditure.CreateLog(c.Context(), service.CreateExpenditureLogInput{
  213. InstitutionID: institutionID,
  214. CampusID: campusID,
  215. ExpenditureID: req.ExpenditureID,
  216. Money: amount,
  217. Remark: req.Remark,
  218. CreateUserID: sc.UserID,
  219. })
  220. if err != nil {
  221. return response.Fail(c, err) // 2002/3001/1004/5002
  222. }
  223. return response.OK(c, createdExpenditureLogView{ID: created.ID, BizNo: created.BizNo})
  224. }
  225. // ListExpenditureTypes 收支类型字典(端点 60 `GET /admin/expenditures/types`)。
  226. //
  227. // 入参(query):`institutionId?`(传了则非超管必须等于作用域机构,否则 1004;不传 = 作用域机构)。
  228. // 出参(成功):`{types:[{id,type,name,parentId}]}`(按 `type,parentId,id` 递增)。
  229. // 出参(失败):1001/2002(`institutionId` 非法)/**1004**/5002。
  230. //
  231. // ⚠ **平台通用字典(`institution_id = 0`)恒在结果里**。若被作用域过滤掉,机构视角下
  232. // "新增收支"的下拉框会只剩自己建的几条(通常 0 条)⇒ 功能看起来是坏的,而数据其实好好的。
  233. // 超管未指定机构时只返回平台字典(否则多机构的同名类型会混在一起,选项重复、父子关系错乱)。
  234. func (h *Handler) ListExpenditureTypes(c *fiber.Ctx) error {
  235. sc, err := mustScope(c)
  236. if err != nil {
  237. return response.Fail(c, err)
  238. }
  239. institutionID, err := optionalInstitutionQuery(c, sc)
  240. if err != nil {
  241. return response.Fail(c, err)
  242. }
  243. svc := h.servicesFor(sc)
  244. types, err := svc.expenditure.ListTypes(c.Context(), institutionID)
  245. if err != nil {
  246. return response.Fail(c, err)
  247. }
  248. views := make([]expenditureTypeView, 0, len(types))
  249. for i := range types {
  250. views = append(views, expenditureTypeView{
  251. ID: types[i].ID,
  252. Type: types[i].Type,
  253. Name: types[i].Name,
  254. ParentID: types[i].ParentID,
  255. })
  256. }
  257. return response.OK(c, expenditureTypesResp{Types: views})
  258. }
  259. // optionalInstitutionQuery 解析可选的 `institutionId` query(**super 可借此收窄,其余必须一致**)。
  260. //
  261. // 入参:c;sc 作用域。出参:(institutionID, error):
  262. // - 未传 → (0, nil):交给作用域(普通用户由 SQL 层 `Scoped` 强制;超管 = 跨机构视角);
  263. // - 传了 >0 → 走 `resolveInstitutionID`(非超管不等于作用域机构 → **1004**);
  264. // - 传了 ≤0 → **2002**("给了但无意义",不静默当没传——否则前端拼错参数名会毫无反馈)。
  265. func optionalInstitutionQuery(c *fiber.Ctx, sc *appscope.Scope) (int64, error) {
  266. raw := strings.TrimSpace(c.Query("institutionId"))
  267. if raw == "" {
  268. return 0, nil
  269. }
  270. value, err := parseIntQuery(c, "institutionId")
  271. if err != nil {
  272. return 0, err
  273. }
  274. if value <= 0 {
  275. return 0, infraerrors.WithCause(infraerrors.ErrBadFormat,
  276. errString("institutionId 必须 > 0(不筛机构请不要传该参数)"))
  277. }
  278. return resolveInstitutionID(sc, value)
  279. }
  280. // timePtr 把 (时间, 是否传了) 归一为可选指针(未传 → nil,交给仓储"不筛")。
  281. //
  282. // 入参:t 解析结果;has 是否传了。出参:*time.Time。
  283. func timePtr(t time.Time, has bool) *time.Time {
  284. if !has {
  285. return nil
  286. }
  287. return &t
  288. }
  289. // normalizeToUpperBound 归一 `to` 上界:纯日期(`2006-01-02`)→ 次日 00:00(配合仓储的开区间 `<`)。
  290. //
  291. // 入参:raw 原始 query;parsed 已解析的时间。出参:用于 `create_time < ?` 的上界。
  292. //
  293. // 判定"纯日期"用**原始串长度**(10),而不是猜时间是否为 0 点:前端真传
  294. // `2026-04-10T00:00:00Z` 时语义是"到该时刻为止"(开区间),不该被自动扩成整天——
  295. // 两种情况字节不同、意图不同,用长度判定最诚实。
  296. func normalizeToUpperBound(raw string, parsed time.Time) time.Time {
  297. if len(strings.TrimSpace(raw)) == len("2006-01-02") {
  298. return parsed.AddDate(0, 0, 1)
  299. }
  300. return parsed
  301. }
  302. // effectiveInstitution 折算"本次请求实际作用于哪个机构"(供 `assertScopeMatches` 使用)。
  303. //
  304. // 入参:sc 作用域(可 nil);institutionID 显式传的机构(0 = 未传)。
  305. // 出参:机构 ID(显式优先;否则作用域机构;都取不到 → 0)。
  306. //
  307. // 用途:把"可选的 institutionId query"与"作用域机构"合成一个确定值,再交给统一的
  308. // `assertScopeMatches` 做越权判断——**避免每个 Handler 各写一遍"显式 or 作用域"的分支**,
  309. // 那种重复分支最容易出现"查询侧校验了、写入侧忘了"的不对称。
  310. func effectiveInstitution(sc *appscope.Scope, institutionID int64) int64 {
  311. if institutionID > 0 {
  312. return institutionID
  313. }
  314. if sc == nil {
  315. return 0
  316. }
  317. return sc.InstitutionID
  318. }