expenditure_service.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. // 收支记录 + 收支字典服务(端点 60)——AC-13d 第三批。
  2. //
  3. // 依据:unified/03-migration/academic-frontend-contract-v1.md §2.5 行 60 / §3.4 D 表 60(v1.9 重写);
  4. // 设计 `academic-domain-design.md` §4.5 端点清单(角色 `super_admin, finance`)。
  5. //
  6. // ---------------------------------------------------------------------------
  7. // 为什么新增(AC-10 / AC-11 / AC-13c 都没覆盖过这张表)
  8. // ---------------------------------------------------------------------------
  9. //
  10. // 能力 现状(此前批次) 冲突
  11. // ListLogs `t_expenditure_log` 从未被读取 无(新只读查询)
  12. // ListTypes `t_expenditure` 从未被读取 无(新只读查询)
  13. // CreateLog 从未有过写入面(资金域的钱走 无(新事务;**不碰**
  14. // `t_user_expense*` 与学生钱包, `t_user_expense*`,
  15. // 与"机构收支台账"是两套账) 两套账不得混)
  16. //
  17. // 三条硬约束(团队批准):不改既有方法签名与行为、新增放新文件、逐条登记(上表)。
  18. //
  19. // ---------------------------------------------------------------------------
  20. // ⚠ 两套账不能混(这是本端点最容易犯的错)
  21. // ---------------------------------------------------------------------------
  22. //
  23. // 学生钱包 / 消费 → `t_user_expense` / `t_user_expense_school` / `t_user_expense_log`(端点 2~9)
  24. // 机构收支台账 → `t_expenditure_log`(本端点)
  25. //
  26. // 二者**没有任何列级联动**:记账不会改学生余额,学生退费也不会自动生成一条机构支出。
  27. // 把两者混起来算"利润"是产品口径问题,不是后端能替它决定的;本服务只做台账 CRUD。
  28. //
  29. // ---------------------------------------------------------------------------
  30. // 三处口径(都写在这里,避免下一批改的时候靠猜)
  31. // ---------------------------------------------------------------------------
  32. //
  33. // 1. **`cost` 恒存正数**,方向由字典 `type`(1 支出 / 2 收入)决定(model 列红线)。
  34. // ⇒ 入参 `money` 必须 > 0:负数会让"支出 + 负数"与"收入 + 正数"变成同一条记录的两种写法,
  35. // 报表口径立刻分叉。传 0 也拒(零元记账没有业务意义,只会污染对账)。
  36. // 2. **`bizNo` 由后端生成**(26 位 ULID):前端不能指定单号,否则可枚举/可撞号;
  37. // `biz_no` 是唯一键,撞了就是 5002(不该等到那时候才发现)。
  38. // 3. **`year` / `month` 分桶列按"库认定的落库时刻"算**(`create_time` 由 DB 默认值填,
  39. // 应用插入后回读再回写分桶)。用应用时钟会在跨月那一秒把记录记到错月份,
  40. // 而报表上只表现为"某月少一条"。
  41. package service
  42. import (
  43. "context"
  44. "strings"
  45. "time"
  46. "gorm.io/gorm"
  47. "yuansiwei/unified-backend/internal/academic/model"
  48. "yuansiwei/unified-backend/internal/academic/money"
  49. "yuansiwei/unified-backend/internal/academic/repository"
  50. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  51. appscope "yuansiwei/unified-backend/internal/infra/scope"
  52. )
  53. // 收支方向枚举(与 `t_expenditure.type` 列一致;契约 D 表:1 支出 / 2 收入)。
  54. const (
  55. // ExpenditureDirectionExpense 支出(1)。
  56. ExpenditureDirectionExpense int32 = 1
  57. // ExpenditureDirectionIncome 收入(2)。
  58. ExpenditureDirectionIncome int32 = 2
  59. )
  60. // ExpenditureService 机构收支台账服务(端点 60 的三条路由)。
  61. type ExpenditureService struct {
  62. // repo 收支仓储(记录走 scoping,字典按"0 = 平台通用"特判)。
  63. repo *repository.ExpenditureRepo
  64. // runner 事务运行器(记账 = 校验字典 + 插记录 + 回写分桶,必须同一事务)。
  65. runner *repository.TxRunner
  66. // sc 请求作用域(机构/校区越权断言)。
  67. sc *appscope.Scope
  68. // now 时钟(仅用于判断"入参是否给出日期"等非落库时间场景;落库时间一律取 DB)。
  69. now func() time.Time
  70. }
  71. // NewExpenditureService 构造收支台账服务。
  72. //
  73. // 入参:db 连接池;sc 请求作用域(nil = 系统任务视角,此时记录查询会 fail-fast)。
  74. // 出参:*ExpenditureService。
  75. func NewExpenditureService(db *gorm.DB, sc *appscope.Scope) *ExpenditureService {
  76. return &ExpenditureService{
  77. repo: repository.NewExpenditureRepo(db, sc),
  78. runner: repository.NewTxRunner(db),
  79. sc: sc,
  80. now: time.Now,
  81. }
  82. }
  83. // ExpenditureLogQuery 端点 60 GET 记录的查询入参。
  84. //
  85. // 字段:InstitutionID 机构(0 = 依赖作用域;超管可借此收窄);CampusID 校区(nil 不筛);
  86. // ExpenditureID 收支类型(nil 不筛);Type 方向(nil 不筛);From/To 创建时间区间(含两端);
  87. // Page/PageSize 分页。
  88. type ExpenditureLogQuery struct {
  89. InstitutionID int64
  90. CampusID *int64
  91. ExpenditureID *int64
  92. Type *int32
  93. From *time.Time
  94. To *time.Time
  95. Page int
  96. PageSize int
  97. }
  98. // ExpenditureLogView 收支记录对外视图(契约 D 表 60 的 9 个字段,**逐列溯源**)。
  99. //
  100. // 字段来源:
  101. //
  102. // ID ← t_expenditure_log.id
  103. // BizNo ← biz_no(业务单号,对外唯一标识;不可变)
  104. // ExpenditureID ← expenditure_id(**补这个字段**,否则前端无法回填编辑表单)
  105. // Type ← t_expenditure.type(**字典**:1 支出 / 2 收入;记录表无此列)
  106. // Name ← t_expenditure.name(**字典**;记录表无此列)
  107. // Money ← cost(金额 2 位小数;**列名是 cost、字段名是 money**,契约口径)
  108. // CreateUserID ← create_user_id(**记账人**,可空 → `null`;不是"收付对象",记录表没有那列)
  109. // CampusID ← campus_id
  110. // CreateTime ← create_time
  111. //
  112. // 契约里原有的 `direction` / `userId` **不下发**:前者与 `type` 同义(两表都没有该列),
  113. // 后者语义是"收付对象"而记录表只有"记账人"(改名 `createUserId` 才不误导)。
  114. type ExpenditureLogView struct {
  115. ID int64
  116. BizNo string
  117. ExpenditureID int64
  118. Type int32
  119. Name string
  120. Money money.Amount
  121. CreateUserID *string
  122. CampusID int64
  123. CreateTime time.Time
  124. }
  125. // ExpenditureTypeView 收支字典项(契约:`{id, type, name, parentId}`)。
  126. //
  127. // 字段来源:ID ← id;Type ← type;Name ← name;ParentID ← parent_id(0 = 顶级,自引用树)。
  128. type ExpenditureTypeView struct {
  129. ID int64
  130. Type int32
  131. Name string
  132. ParentID int64
  133. }
  134. // CreateExpenditureLogInput 端点 60 POST 记账入参。
  135. //
  136. // 字段:InstitutionID(必填 >0,非超管须等于作用域机构);CampusID(0 = 机构级);
  137. // ExpenditureID(必填 >0,须在字典中且对本机构可见);Money(必填 >0,元字符串解析后传入);
  138. // Remark(可空);CreateUserID(记账人 = 当前登录用户,由 Handler 从作用域取)。
  139. type CreateExpenditureLogInput struct {
  140. InstitutionID int64
  141. CampusID int64
  142. ExpenditureID int64
  143. Money money.Amount
  144. Remark string
  145. CreateUserID string
  146. }
  147. // CreatedExpenditureLog 记账结果(契约:POST → `{id, bizNo}`)。
  148. type CreatedExpenditureLog struct {
  149. // ID 记录 ID(自增)。
  150. ID int64
  151. // BizNo 业务单号(后端生成的 26 位 ULID)。
  152. BizNo string
  153. }
  154. // ListLogs 分页查询收支记录(字典名/方向由批量查字典补齐)。
  155. //
  156. // 入参:ctx;q 查询入参。
  157. // 出参:([]ExpenditureLogView, total, error);1004(跨机构)/5002。
  158. //
  159. // 为什么"分页取记录 + 批量取字典"而不是 SQL JOIN:记录表走 `Scoped` 生成的是**裸列名**,
  160. // JOIN 后 `institution_id` 会 `ambiguous column name`(本项目已踩过);
  161. // 且两步走能让"平台通用字典必须放行"这条规则只出现在 `ListTypesByIDs` 一处。
  162. func (s *ExpenditureService) ListLogs(ctx context.Context, q ExpenditureLogQuery) ([]ExpenditureLogView, int64, error) {
  163. if err := s.assertInstitution(q.InstitutionID); err != nil {
  164. return nil, 0, err
  165. }
  166. rows, total, err := s.repo.ListLogs(ctx, repository.ExpenditureLogFilter{
  167. InstitutionID: q.InstitutionID,
  168. CampusID: q.CampusID,
  169. ExpenditureID: q.ExpenditureID,
  170. Type: q.Type,
  171. From: q.From,
  172. To: q.To,
  173. }, repository.PageQuery{Page: q.Page, PageSize: q.PageSize})
  174. if err != nil {
  175. return nil, 0, err
  176. }
  177. if len(rows) == 0 {
  178. return make([]ExpenditureLogView, 0), total, nil
  179. }
  180. // 批量取字典(**含平台通用字典**),避免 N+1;`expenditure_id = 0` 不查(不存在该 id)。
  181. ids := make([]int64, 0, len(rows))
  182. seen := make(map[int64]struct{}, len(rows))
  183. for i := range rows {
  184. id := rows[i].ExpenditureID
  185. if id <= 0 {
  186. continue
  187. }
  188. if _, ok := seen[id]; ok {
  189. continue
  190. }
  191. seen[id] = struct{}{}
  192. ids = append(ids, id)
  193. }
  194. types, err := s.repo.ListTypesByIDs(ctx, ids, s.dictionaryInstitution(q.InstitutionID))
  195. if err != nil {
  196. return nil, 0, err
  197. }
  198. dict := make(map[int64]*model.Expenditure, len(types))
  199. for i := range types {
  200. dict[types[i].ID] = &types[i]
  201. }
  202. views := make([]ExpenditureLogView, 0, len(rows))
  203. for i := range rows {
  204. row := &rows[i]
  205. cost, perr := parseAmount(row.Cost)
  206. if perr != nil {
  207. return nil, 0, perr
  208. }
  209. view := ExpenditureLogView{
  210. ID: row.ID,
  211. BizNo: row.BizNo,
  212. ExpenditureID: row.ExpenditureID,
  213. Money: cost,
  214. CreateUserID: optionalTrimmedPtr(row.CreateUserID),
  215. CampusID: row.CampusID,
  216. CreateTime: row.CreateTime,
  217. }
  218. // 字典命中才有 type/name;未命中保持 0 / ""(见下)。
  219. if t, ok := dict[row.ExpenditureID]; ok {
  220. view.Type = t.Type
  221. view.Name = strings.TrimSpace(t.Name)
  222. }
  223. // ⚠ 字典未命中(类型被删 / `expenditure_id = 0`)时 **type=0、name=""**,不编造"未知类型":
  224. // 编一个字符串等于凭空造业务值(契约 §3.4 ③:每个字段必须能追到列,造的值追不到),
  225. // 而且前端再想按 type 分组时会被这个假值带偏。空值至少诚实。
  226. views = append(views, view)
  227. }
  228. return views, total, nil
  229. }
  230. // ListTypes 取收支字典(契约:`GET /admin/expenditures/types` → `types:[{id,type,name,parentId}]`)。
  231. //
  232. // 入参:ctx;institutionID 机构(<=0 = 只取平台通用字典,见 `dictionaryInstitution`)。
  233. // 出参:([]ExpenditureTypeView, error);1004(跨机构)/5002。
  234. //
  235. // **平台通用字典(`institution_id = 0`)恒在结果里**:它不是"未归属数据",
  236. // 而是全平台共用的类型(如"房租/水电/学费收入")。若被作用域过滤掉,
  237. // 机构视角下"新增收支"的下拉框会只剩自己建的几条(通常为 0 条)⇒ 功能像是坏了。
  238. func (s *ExpenditureService) ListTypes(ctx context.Context, institutionID int64) ([]ExpenditureTypeView, error) {
  239. if err := s.assertInstitution(institutionID); err != nil {
  240. return nil, err
  241. }
  242. rows, err := s.repo.ListTypes(ctx, s.dictionaryInstitution(institutionID))
  243. if err != nil {
  244. return nil, err
  245. }
  246. views := make([]ExpenditureTypeView, 0, len(rows))
  247. for i := range rows {
  248. views = append(views, ExpenditureTypeView{
  249. ID: rows[i].ID,
  250. Type: rows[i].Type,
  251. Name: strings.TrimSpace(rows[i].Name),
  252. ParentID: rows[i].ParentID,
  253. })
  254. }
  255. return views, nil
  256. }
  257. // CreateLog 记一笔收支(校验字典可见性 → 插记录 → 按库时间回写分桶,**同一事务**)。
  258. //
  259. // 入参:ctx;in CreateExpenditureLogInput。
  260. // 出参:*CreatedExpenditureLog(id + bizNo);2001(缺机构/类型)/2002(金额≤0、校区为负)/
  261. // 3001(收支类型不存在或不可见)/1004(跨机构/跨校区)/5002。
  262. func (s *ExpenditureService) CreateLog(ctx context.Context, in CreateExpenditureLogInput) (*CreatedExpenditureLog, error) {
  263. if in.InstitutionID <= 0 {
  264. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  265. errString("service: 记账需要 institutionId(>0)"))
  266. }
  267. if in.ExpenditureID <= 0 {
  268. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  269. errString("service: 记账需要 expenditureId(>0)"))
  270. }
  271. if in.CampusID < 0 {
  272. return nil, infraerrors.WithCause(infraerrors.ErrBadFormat,
  273. errString("service: campusId 不得为负"))
  274. }
  275. if !(in.Money.GreaterThan(0)) {
  276. // 零/负都拒:`cost` 恒存正数,方向由字典 type 决定(model 列红线)。
  277. // 放行负数会让"支出 -100"与"收入 +100"成为同一条记录的两种写法,报表口径立刻分叉。
  278. return nil, infraerrors.WithCause(infraerrors.ErrBadFormat,
  279. errString("service: 记账金额必须大于 0(收支方向由 expenditureId 对应的 type 决定,"+
  280. "cost 恒存正数;传负数会与反方向的正数记账重复)"))
  281. }
  282. if err := in.Money.Validate(); err != nil {
  283. return nil, infraerrors.WithCause(infraerrors.ErrBadFormat, err)
  284. }
  285. if err := s.assertScopeMatch(in.InstitutionID, in.CampusID); err != nil {
  286. return nil, err
  287. }
  288. bizNo, err := newBizNo()
  289. if err != nil {
  290. return nil, err
  291. }
  292. row := &model.ExpenditureLog{
  293. BizNo: bizNo,
  294. InstitutionID: in.InstitutionID,
  295. CampusID: in.CampusID,
  296. ExpenditureID: in.ExpenditureID,
  297. // 金额列是 `decimal(12,2)` 字符串:走 `money.Amount.String()`(定点,恒 2 位小数)。
  298. Cost: in.Money.String(),
  299. CreateUserID: stringPtr(in.CreateUserID),
  300. Remark: stringPtr(in.Remark),
  301. }
  302. err = s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  303. txCtx := repository.WithTxContext(ctx, tx)
  304. // 字典可见性校验放在事务内:并发删字典时不会出现"校验通过但字典已没了"的窗口。
  305. if _, terr := s.repo.GetTypeByID(txCtx, in.ExpenditureID, s.dictionaryInstitution(in.InstitutionID)); terr != nil {
  306. return terr // 2002(id 非法)/ 3001(不存在或不可见)/ 5002
  307. }
  308. if cerr := s.repo.CreateLog(txCtx, tx, row); cerr != nil {
  309. return cerr
  310. }
  311. // 分桶按**库认定的落库时刻**算(`create_time` 由 DB 默认值填,应用侧时钟会跨月错桶)。
  312. year := int16(row.CreateTime.Year())
  313. month := int8(row.CreateTime.Month())
  314. return s.repo.SetLogBuckets(txCtx, tx, row.ID, year, month)
  315. })
  316. if err != nil {
  317. return nil, err
  318. }
  319. return &CreatedExpenditureLog{ID: row.ID, BizNo: row.BizNo}, nil
  320. }
  321. // assertInstitution 机构维度越权断言(非超管必须与作用域机构一致)。
  322. //
  323. // 入参:institutionID(0 = 未指定,依赖作用域,放行)。出参:1004 或 nil。
  324. //
  325. // 为什么要单独一道(`Scoped` 已经在 SQL 层过滤):SQL 层过滤会把"跨机构查询"变成**空列表**,
  326. // 症状是"这家机构一条收支都没有",而不是"你越权了"。查询类端点必须显式报 1004,
  327. // 否则前端会以为数据丢了(本项目在 AC-13c 已按此口径统一)。
  328. func (s *ExpenditureService) assertInstitution(institutionID int64) error {
  329. if s.sc == nil {
  330. return infraerrors.WithCause(infraerrors.ErrInternal, errString("service: 收支服务缺少作用域"))
  331. }
  332. if s.sc.SuperAdmin || institutionID <= 0 {
  333. return nil
  334. }
  335. if institutionID != s.sc.InstitutionID {
  336. return infraerrors.WithCause(infraerrors.ErrUnscoped,
  337. errString("service: 收支记录机构与当前作用域机构不一致"))
  338. }
  339. return nil
  340. }
  341. // assertScopeMatch 写入时的机构 + 校区双维度断言(非超管)。
  342. //
  343. // 入参:institutionID;campusID。出参:1004 或 nil。
  344. //
  345. // 校区规则与课程域一致:作用域带校区(>0)时,写入的 `campus_id` 必须等于它,
  346. // 否则就是"往别的校区记账"——而收支记录是**按校区出报表**的,越权写入会直接污染他校区报表。
  347. func (s *ExpenditureService) assertScopeMatch(institutionID, campusID int64) error {
  348. if s.sc == nil {
  349. return infraerrors.WithCause(infraerrors.ErrInternal, errString("service: 收支服务缺少作用域"))
  350. }
  351. if s.sc.SuperAdmin {
  352. return nil
  353. }
  354. if institutionID != s.sc.InstitutionID {
  355. return infraerrors.WithCause(infraerrors.ErrUnscoped,
  356. errString("service: 记账机构与当前作用域机构不一致"))
  357. }
  358. if s.sc.CampusID > 0 && campusID != s.sc.CampusID {
  359. return infraerrors.WithCause(infraerrors.ErrUnscoped,
  360. errString("service: 记账校区与当前作用域校区不一致"))
  361. }
  362. return nil
  363. }
  364. // dictionaryInstitution 折算"查字典时该用哪个机构"。
  365. //
  366. // 入参:institutionID 查询/写入的机构(可 0)。出参:用于字典过滤的机构 ID(0 = 只取平台通用字典)。
  367. //
  368. // 规则:调用方给了机构就用它;没给(超管未指定)→ 用作用域机构;仍为 0 → 只取平台字典。
  369. // **任何一支都包含 `institution_id = 0`**(见 repo.dictionaryScope):平台通用字典永不缺席。
  370. func (s *ExpenditureService) dictionaryInstitution(institutionID int64) int64 {
  371. if institutionID > 0 {
  372. return institutionID
  373. }
  374. if s.sc != nil {
  375. return s.sc.InstitutionID
  376. }
  377. return 0
  378. }