expenditure_repo.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. // 收支字典 + 收支记录的数据访问(端点 60:`/admin/expenditures`)——AC-13d 第三批。
  2. //
  3. // 依据:unified/03-migration/academic-frontend-contract-v1.md §2.5 行 60 / §3.4 D 表 60(v1.9 重写);
  4. // 目标态 DDL 两张表:
  5. //
  6. // t_expenditure : id, type, parent_id, name, remark, institution_id
  7. // t_expenditure_log : id, biz_no, institution_id, campus_id, expenditure_id, cost,
  8. // create_user_id, create_time, year, month, remark, imgs
  9. //
  10. // 端点 60 的三条口径(契约 v1.9 已定,这里只实现,不再议):
  11. //
  12. // ① `type` / `name` 记录表**没有这两列** ⇒ 必须查字典 `t_expenditure`:
  13. // `type`(1 支出 / 2 收入) 与 `name`(类型名)都从字典拿;同时补 `expenditureId`,
  14. // 否则前端无法回填编辑表单(有 name 没 id,改不了)。
  15. // ② 不实现 `direction`:与 `type` 是同一事实的两个名字,两表都没有该列。
  16. // ③ `userId` → `createUserId`(← `create_user_id`,语义 = **记账人**)。
  17. // 记录表**没有"收付对象"列**,需要只能加列 + 迁移(本轮不做)。
  18. //
  19. // ---------------------------------------------------------------------------
  20. // ⚠ 本文件最要紧的一处:**平台通用字典(`institution_id = 0`)不能被作用域过滤掉**
  21. // ---------------------------------------------------------------------------
  22. //
  23. // `t_expenditure.institution_id` 的 `0` **不是**"未归属/脏数据",而是**平台通用字典**
  24. // (model.Expenditure 列注释:"0=平台通用字典")。机构可以自建类型,但绝大多数类型是平台那批。
  25. //
  26. // 若对字典表也套 `BaseRepo.Scoped`(生成 `WHERE institution_id = 1`),后果是:
  27. //
  28. // 记录列表 JOIN 不到类型名 ⇒ 所有"平台通用类型"的收支记录 `name` 全是空、`type` 全是 0;
  29. // 而记录本身查得到、金额分页都对、`bizNo` 都在 —— **只有"类型"这一列静默变空**。
  30. // 前端会把这批记录渲染成"未知类型",用户以为数据丢了,实际是被作用域过滤掉的字典行。
  31. //
  32. // 更糟的是这种缺陷**在机构视角下"看起来像没配字典"**,排查方向会先跑到"字典维护页是不是漏了"。
  33. //
  34. // 因此本文件对字典表的读取**刻意不用 `Scoped`**,而是显式写:
  35. //
  36. // WHERE (institution_id = 0 OR institution_id = ?) -- ? = 请求机构(超管无机构时只取 0)
  37. //
  38. // 记录表(`t_expenditure_log`)**有**真实作用域列且无平台行语义 ⇒ 照常走 `Scoped`。
  39. //
  40. // ---------------------------------------------------------------------------
  41. // 两条实现纪律
  42. // ---------------------------------------------------------------------------
  43. //
  44. // 1. **不做 SQL JOIN**(虽然契约文案写的是 "JOIN 字典"):记录表走 `Scoped` 生成的是**裸列名**
  45. // (`institution_id = ?`),一旦 JOIN 就 `ambiguous column name: institution_id`
  46. // (本项目已踩过:`ListStudentSchedule`)。改为"分页取记录 → 按 `expenditure_id` 批量取字典"
  47. // 两步走,语义等价、无歧义、且字典命中天然带上了"平台通用"那一批。
  48. // ⇒ 反过来看:**两步走不是妥协,而是让"平台字典必须放行"这条规则只出现在一处**(`ListTypesByIDs`)。
  49. // 2. `type` 过滤走**子查询**(`expenditure_id IN (SELECT id FROM t_expenditure WHERE type = ? ...)`),
  50. // 不下发"先查一批 id 再 IN":字典可能上百行,把 IN 列表拼进 SQL 反而更容易写错。
  51. package repository
  52. import (
  53. "context"
  54. "strings"
  55. "time"
  56. "gorm.io/gorm"
  57. "yuansiwei/unified-backend/internal/academic/model"
  58. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  59. appscope "yuansiwei/unified-backend/internal/infra/scope"
  60. )
  61. // ExpenditureRepo 收支记录 + 收支字典仓储。
  62. //
  63. // 作用域:记录表 `t_expenditure_log` 带 `institution_id`/`campus_id` ⇒ `CampusScoped=true`;
  64. // 字典表按"0 = 平台通用"特判(见文件头),不走 `Scoped`。
  65. type ExpenditureRepo struct {
  66. *BaseRepo
  67. }
  68. // NewExpenditureRepo 构造收支仓储。
  69. //
  70. // 入参:db 连接池;sc 请求作用域(nil = 系统任务视角——字典读取需要特判,记录查询会 panic,见 Scoped)。
  71. // 出参:*ExpenditureRepo(CampusScoped=true)。
  72. func NewExpenditureRepo(db *gorm.DB, sc *appscope.Scope) *ExpenditureRepo {
  73. r := &ExpenditureRepo{BaseRepo: NewBaseRepo(db, sc)}
  74. r.CampusScoped = true
  75. return r
  76. }
  77. // ExpenditureLogFilter 收支记录列表过滤(端点 60 GET)。
  78. //
  79. // 字段:
  80. // - InstitutionID:机构(0 = 不额外筛,作用域由 `Scoped` 强制;非超管传了别的机构 → Service 层 1004);
  81. // - CampusID:校区(nil 不筛);
  82. // - ExpenditureID:收支类型(nil 不筛;按字典 id 精确匹配);
  83. // - Type:收支方向(nil 不筛;1 支出 / 2 收入,走字典子查询);
  84. // - From / To:创建时间区间(含两端;nil 不筛)。
  85. //
  86. // ⚠ `Type` 与 `ExpenditureID` 同时给出时是**交集**(正常业务不会传,传了也不该"或")。
  87. type ExpenditureLogFilter struct {
  88. InstitutionID int64
  89. CampusID *int64
  90. ExpenditureID *int64
  91. Type *int32
  92. From *time.Time
  93. To *time.Time
  94. }
  95. // ListLogs 分页查询收支记录(按 `create_time DESC, id DESC`:最新记账在前)。
  96. //
  97. // 入参:ctx;f 过滤;pg 分页。
  98. // 出参:([]model.ExpenditureLog, total, error);空结果非 nil 由调用方保证;ErrDBError(5002)。
  99. //
  100. // 排序为什么带 `id DESC` 兜底:`create_time` 是 `datetime(3)`,同一毫秒批量导入会出现重复值,
  101. // 只按时间排序时**分页边界会重复/漏行**(MySQL 对相等键的排序不稳定)。加主键兜底即确定性。
  102. func (r *ExpenditureRepo) ListLogs(ctx context.Context, f ExpenditureLogFilter, pg PageQuery) ([]model.ExpenditureLog, int64, error) {
  103. db, err := r.db(ctx)
  104. if err != nil {
  105. return nil, 0, err
  106. }
  107. build := func() *gorm.DB {
  108. q := r.Scoped(db.Model(&model.ExpenditureLog{}))
  109. if f.InstitutionID > 0 {
  110. // 超管跨机构时用于收窄;非超管与 Scoped 同值(重复条件是等价的,只为让超管能筛)。
  111. q = q.Where("institution_id = ?", f.InstitutionID)
  112. }
  113. if f.CampusID != nil {
  114. q = q.Where("campus_id = ?", *f.CampusID)
  115. }
  116. if f.ExpenditureID != nil {
  117. q = q.Where("expenditure_id = ?", *f.ExpenditureID)
  118. }
  119. if f.Type != nil {
  120. q = q.Where("expenditure_id IN (?)", r.typeSubquery(db, *f.Type))
  121. }
  122. if f.From != nil {
  123. q = q.Where("create_time >= ?", *f.From)
  124. }
  125. if f.To != nil {
  126. // **上界是开区间**(`<`):Handler 已把"纯日期"形态补到次日 00:00
  127. // (`to=2026-04-10` 含当日全部流水)。若这里写成 `<=`,
  128. // 次日 00:00:00.000 的那条会被多算进前一天——与端点 17(账单 `to` 不含上界)同口径。
  129. q = q.Where("create_time < ?", *f.To)
  130. }
  131. return q
  132. }
  133. var total int64
  134. if err := build().Count(&total).Error; err != nil {
  135. return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
  136. }
  137. page, size := pg.Normalize()
  138. rows := make([]model.ExpenditureLog, 0, size)
  139. if err := build().
  140. Order("create_time DESC, id DESC").
  141. Offset(PageOffset(page, size)).Limit(size).
  142. Find(&rows).Error; err != nil {
  143. return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
  144. }
  145. return rows, total, nil
  146. }
  147. // typeSubquery 构造"某收支方向对应的字典 id 集合"子查询。
  148. //
  149. // 入参:db 查询句柄;typ 收支方向(1 支出 / 2 收入)。
  150. // 出参:*gorm.DB(用作 `expenditure_id IN (?)` 的子查询)。
  151. //
  152. // ⚠ 子查询里**必须**放行 `institution_id = 0`(平台通用字典),理由同文件头:
  153. // 只查本机构会造成"按方向筛选时,平台通用类型的记录整批消失"——比列表少一列更难查。
  154. // 超管(作用域机构为 0 或 SuperAdmin)不额外收窄机构:平台视角下应能看到所有机构的类型。
  155. func (r *ExpenditureRepo) typeSubquery(db *gorm.DB, typ int32) *gorm.DB {
  156. q := db.Model(&model.Expenditure{}).Select("id").Where("type = ?", typ)
  157. if r.Sc == nil || r.Sc.SuperAdmin {
  158. return q
  159. }
  160. return q.Where("institution_id = 0 OR institution_id = ?", r.Sc.InstitutionID)
  161. }
  162. // CreateLog 写入一条收支记录(**接收 tx**,仓储不开事务),并回填 DB 侧落库时间。
  163. //
  164. // 入参:ctx;tx(必传);row 记录行(InstitutionID>0、ExpenditureID>0、Cost 为非负定点字符串)。
  165. // 出参:nil 成功(回填自增 ID 与 `CreateTime`);ErrBadParam(2002) / ErrDBError(5002)。
  166. //
  167. // ⚠ **不写 `create_time`**:该列 `DEFAULT CURRENT_TIMESTAMP`,GORM 对"零值 + default 标签"
  168. // 会跳过该列、由 DB 填(本项目在 `t_courses.status` 上踩过**反向**的坑:`default:1` 让显式 0 被吞)。
  169. // 因此应用侧写 `time.Now()` 是错的(应用与库时钟可能差几分钟,而 `year`/`month` 是按它算的分桶)。
  170. //
  171. // ⇒ 插入后**在同一事务内回读** `create_time` 填进 `row.CreateTime`,让上层用"库认定的落库时刻"
  172. // 去算 `year`/`month`,而不是用应用时钟。差几秒在跨月/跨年的那一瞬间就是:
  173. // **"1 月 31 日 23:59:59 记的账出现在 2 月报表里"**——金额、条数、汇总全对,只有月份错,
  174. // 这种缺陷只能靠对账时"某月少一条、另一月多一条"发现。
  175. func (r *ExpenditureRepo) CreateLog(ctx context.Context, tx *gorm.DB, row *model.ExpenditureLog) error {
  176. if err := requireTx(tx, "CreateLog"); err != nil {
  177. return err
  178. }
  179. if row == nil || row.ExpenditureID <= 0 {
  180. return infraerrors.WithCause(infraerrors.ErrBadParam,
  181. errString("repository: CreateLog 需要 expenditure_id > 0"))
  182. }
  183. if strings.TrimSpace(row.BizNo) == "" {
  184. return infraerrors.WithCause(infraerrors.ErrBadParam,
  185. errString("repository: CreateLog 需要 biz_no"))
  186. }
  187. ctx = ctxOrBackground(ctx)
  188. if err := tx.WithContext(ctx).Create(row).Error; err != nil {
  189. return infraerrors.WithCause(infraerrors.ErrDBError, err)
  190. }
  191. // 回读落库时间(同一事务,读得到自己刚插的行)。
  192. var persisted model.ExpenditureLog
  193. if err := tx.WithContext(ctx).
  194. Select("create_time").
  195. Where("id = ?", row.ID).
  196. Take(&persisted).Error; err != nil {
  197. return infraerrors.WithCause(infraerrors.ErrDBError, err)
  198. }
  199. row.CreateTime = persisted.CreateTime
  200. return nil
  201. }
  202. // SetLogBuckets 回写报表分桶列 `year` / `month`(同一事务;由 Service 按落库时间算出)。
  203. //
  204. // 入参:ctx;tx(必传);id 记录 ID(>0);year 年(如 2026);month 月(1~12)。
  205. // 出参:nil 成功;ErrBadParam(2002) / ErrDBError(5002)。
  206. //
  207. // 单独一次 UPDATE 而不是塞进 INSERT:`create_time` 由 DB 默认值决定,INSERT 时**还没读到**它,
  208. // 硬把应用时钟的 y/m 写进去就是上面 `CreateLog` 注释里那个"跨月错桶"缺陷的来源。
  209. func (r *ExpenditureRepo) SetLogBuckets(ctx context.Context, tx *gorm.DB, id int64, year int16, month int8) error {
  210. if err := requireTx(tx, "SetLogBuckets"); err != nil {
  211. return err
  212. }
  213. if id <= 0 {
  214. return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: SetLogBuckets 需要 id > 0"))
  215. }
  216. if month < 1 || month > 12 {
  217. return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: month 必须在 1~12"))
  218. }
  219. err := tx.WithContext(ctxOrBackground(ctx)).
  220. Model(&model.ExpenditureLog{}).
  221. Where("id = ?", id).
  222. Updates(map[string]any{"year": year, "month": month}).Error
  223. if err != nil {
  224. return infraerrors.WithCause(infraerrors.ErrDBError, err)
  225. }
  226. return nil
  227. }
  228. // ListTypesByIDs 按 id 批量取收支字典(**含平台通用字典**)。
  229. //
  230. // 入参:ctx;ids 类型 id 集合(空 → 空切片,不发 SQL);institutionID 请求机构(0 = 只取平台字典)。
  231. // 出参:([]model.Expenditure, error);ErrDBError(5002)。
  232. //
  233. // 过滤口径:`institution_id = 0 OR institution_id = ?` —— `0` 是平台通用字典,**必须放行**。
  234. // 返回的 map 由 Service 组装,未命中(字典被删/`expenditure_id = 0`)的行保持 `type=0/name=""`,
  235. // **不伪造"未知类型"字符串**(那是新造业务值,契约追溯不到列)。
  236. func (r *ExpenditureRepo) ListTypesByIDs(ctx context.Context, ids []int64, institutionID int64) ([]model.Expenditure, error) {
  237. if len(ids) == 0 {
  238. return make([]model.Expenditure, 0), nil
  239. }
  240. db, err := r.db(ctx)
  241. if err != nil {
  242. return nil, err
  243. }
  244. rows := make([]model.Expenditure, 0, len(ids))
  245. if err := r.dictionaryScope(db, institutionID).Where("id IN ?", ids).Find(&rows).Error; err != nil {
  246. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  247. }
  248. return rows, nil
  249. }
  250. // ListTypes 取某机构的全部收支字典(**含平台通用字典**),按 `type, parent_id, id` 递增。
  251. //
  252. // 入参:ctx;institutionID 请求机构(0 = 只取平台字典)。
  253. // 出参:([]model.Expenditure, error);ErrDBError(5002)。
  254. //
  255. // 排序口径(稳定输出,便于前端做两级联动):先按 `type`(1 支出 / 2 收入,支出在前),
  256. // 再按 `parent_id`(顶级 0 在前),最后 `id`。SQL 里显式写序,不依赖存储顺序。
  257. func (r *ExpenditureRepo) ListTypes(ctx context.Context, institutionID int64) ([]model.Expenditure, error) {
  258. db, err := r.db(ctx)
  259. if err != nil {
  260. return nil, err
  261. }
  262. rows := make([]model.Expenditure, 0, 32)
  263. if err := r.dictionaryScope(db, institutionID).
  264. Order("type ASC, parent_id ASC, id ASC").
  265. Find(&rows).Error; err != nil {
  266. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  267. }
  268. return rows, nil
  269. }
  270. // GetTypeByID 取单个收支字典项(**含平台通用字典**)。
  271. //
  272. // 入参:ctx;id 类型 id(<=0 → ErrBadParam);institutionID 请求机构(0 = 只认平台字典)。
  273. // 出参:*model.Expenditure;ErrBadParam(2002) / ErrNotFound(3001) / ErrDBError(5002)。
  274. //
  275. // 用途(唯一):端点 60 POST 记账时校验 `expenditureId` 真实存在且对本机构可见。
  276. // 不存在与"别人的私有类型"**同码 3001**(不泄露存在性)。
  277. func (r *ExpenditureRepo) GetTypeByID(ctx context.Context, id, institutionID int64) (*model.Expenditure, error) {
  278. if id <= 0 {
  279. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: 收支类型 id 必须 > 0"))
  280. }
  281. db, err := r.db(ctx)
  282. if err != nil {
  283. return nil, err
  284. }
  285. var out model.Expenditure
  286. qErr := r.dictionaryScope(db, institutionID).Where("id = ?", id).Take(&out).Error
  287. if qErr != nil {
  288. return nil, wrapQueryErr(qErr, "GetTypeByID(t_expenditure)")
  289. }
  290. return &out, nil
  291. }
  292. // dictionaryScope 字典表的作用域条件(**唯一实现点**:`0` = 平台通用字典必须放行)。
  293. //
  294. // 入参:db;institutionID 请求机构(<=0 = 只取平台字典)。
  295. // 出参:*gorm.DB(已注入 `institution_id` 条件)。
  296. //
  297. // 为什么机构为 0 时**不**写成"不过滤":超管没有机构上下文时若不过滤,会把所有机构的同名类型
  298. // 混在一起(`parentId` 还是各机构自己的 id),下拉框选项重复且父子关系错乱。
  299. // 因此超管未指定机构时**只看平台字典**,要看某机构的私有类型就显式传 `institutionId`。
  300. func (r *ExpenditureRepo) dictionaryScope(db *gorm.DB, institutionID int64) *gorm.DB {
  301. if institutionID <= 0 {
  302. return db.Where("institution_id = 0")
  303. }
  304. return db.Where("institution_id = 0 OR institution_id = ?", institutionID)
  305. }
  306. // db 取查询句柄(ctx 内含 tx 时复用同一事务,保证事务内读一致)。
  307. //
  308. // 入参:ctx(可 nil)。出参:(*gorm.DB, error);未装配 DB → ErrInternal。
  309. func (r *ExpenditureRepo) db(ctx context.Context) (*gorm.DB, error) {
  310. if r == nil || r.DB == nil {
  311. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: ExpenditureRepo.DB 为 nil"))
  312. }
  313. if tx := TxFromContext(ctx); tx != nil {
  314. return tx, nil
  315. }
  316. if ctx == nil {
  317. return r.DB, nil
  318. }
  319. return r.DB.WithContext(ctx), nil
  320. }