| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335 |
- // 收支字典 + 收支记录的数据访问(端点 60:`/admin/expenditures`)——AC-13d 第三批。
- //
- // 依据:unified/03-migration/academic-frontend-contract-v1.md §2.5 行 60 / §3.4 D 表 60(v1.9 重写);
- // 目标态 DDL 两张表:
- //
- // t_expenditure : id, type, parent_id, name, remark, institution_id
- // t_expenditure_log : id, biz_no, institution_id, campus_id, expenditure_id, cost,
- // create_user_id, create_time, year, month, remark, imgs
- //
- // 端点 60 的三条口径(契约 v1.9 已定,这里只实现,不再议):
- //
- // ① `type` / `name` 记录表**没有这两列** ⇒ 必须查字典 `t_expenditure`:
- // `type`(1 支出 / 2 收入) 与 `name`(类型名)都从字典拿;同时补 `expenditureId`,
- // 否则前端无法回填编辑表单(有 name 没 id,改不了)。
- // ② 不实现 `direction`:与 `type` 是同一事实的两个名字,两表都没有该列。
- // ③ `userId` → `createUserId`(← `create_user_id`,语义 = **记账人**)。
- // 记录表**没有"收付对象"列**,需要只能加列 + 迁移(本轮不做)。
- //
- // ---------------------------------------------------------------------------
- // ⚠ 本文件最要紧的一处:**平台通用字典(`institution_id = 0`)不能被作用域过滤掉**
- // ---------------------------------------------------------------------------
- //
- // `t_expenditure.institution_id` 的 `0` **不是**"未归属/脏数据",而是**平台通用字典**
- // (model.Expenditure 列注释:"0=平台通用字典")。机构可以自建类型,但绝大多数类型是平台那批。
- //
- // 若对字典表也套 `BaseRepo.Scoped`(生成 `WHERE institution_id = 1`),后果是:
- //
- // 记录列表 JOIN 不到类型名 ⇒ 所有"平台通用类型"的收支记录 `name` 全是空、`type` 全是 0;
- // 而记录本身查得到、金额分页都对、`bizNo` 都在 —— **只有"类型"这一列静默变空**。
- // 前端会把这批记录渲染成"未知类型",用户以为数据丢了,实际是被作用域过滤掉的字典行。
- //
- // 更糟的是这种缺陷**在机构视角下"看起来像没配字典"**,排查方向会先跑到"字典维护页是不是漏了"。
- //
- // 因此本文件对字典表的读取**刻意不用 `Scoped`**,而是显式写:
- //
- // WHERE (institution_id = 0 OR institution_id = ?) -- ? = 请求机构(超管无机构时只取 0)
- //
- // 记录表(`t_expenditure_log`)**有**真实作用域列且无平台行语义 ⇒ 照常走 `Scoped`。
- //
- // ---------------------------------------------------------------------------
- // 两条实现纪律
- // ---------------------------------------------------------------------------
- //
- // 1. **不做 SQL JOIN**(虽然契约文案写的是 "JOIN 字典"):记录表走 `Scoped` 生成的是**裸列名**
- // (`institution_id = ?`),一旦 JOIN 就 `ambiguous column name: institution_id`
- // (本项目已踩过:`ListStudentSchedule`)。改为"分页取记录 → 按 `expenditure_id` 批量取字典"
- // 两步走,语义等价、无歧义、且字典命中天然带上了"平台通用"那一批。
- // ⇒ 反过来看:**两步走不是妥协,而是让"平台字典必须放行"这条规则只出现在一处**(`ListTypesByIDs`)。
- // 2. `type` 过滤走**子查询**(`expenditure_id IN (SELECT id FROM t_expenditure WHERE type = ? ...)`),
- // 不下发"先查一批 id 再 IN":字典可能上百行,把 IN 列表拼进 SQL 反而更容易写错。
- package repository
- import (
- "context"
- "strings"
- "time"
- "gorm.io/gorm"
- "yuansiwei/unified-backend/internal/academic/model"
- infraerrors "yuansiwei/unified-backend/internal/infra/errors"
- appscope "yuansiwei/unified-backend/internal/infra/scope"
- )
- // ExpenditureRepo 收支记录 + 收支字典仓储。
- //
- // 作用域:记录表 `t_expenditure_log` 带 `institution_id`/`campus_id` ⇒ `CampusScoped=true`;
- // 字典表按"0 = 平台通用"特判(见文件头),不走 `Scoped`。
- type ExpenditureRepo struct {
- *BaseRepo
- }
- // NewExpenditureRepo 构造收支仓储。
- //
- // 入参:db 连接池;sc 请求作用域(nil = 系统任务视角——字典读取需要特判,记录查询会 panic,见 Scoped)。
- // 出参:*ExpenditureRepo(CampusScoped=true)。
- func NewExpenditureRepo(db *gorm.DB, sc *appscope.Scope) *ExpenditureRepo {
- r := &ExpenditureRepo{BaseRepo: NewBaseRepo(db, sc)}
- r.CampusScoped = true
- return r
- }
- // ExpenditureLogFilter 收支记录列表过滤(端点 60 GET)。
- //
- // 字段:
- // - InstitutionID:机构(0 = 不额外筛,作用域由 `Scoped` 强制;非超管传了别的机构 → Service 层 1004);
- // - CampusID:校区(nil 不筛);
- // - ExpenditureID:收支类型(nil 不筛;按字典 id 精确匹配);
- // - Type:收支方向(nil 不筛;1 支出 / 2 收入,走字典子查询);
- // - From / To:创建时间区间(含两端;nil 不筛)。
- //
- // ⚠ `Type` 与 `ExpenditureID` 同时给出时是**交集**(正常业务不会传,传了也不该"或")。
- type ExpenditureLogFilter struct {
- InstitutionID int64
- CampusID *int64
- ExpenditureID *int64
- Type *int32
- From *time.Time
- To *time.Time
- }
- // ListLogs 分页查询收支记录(按 `create_time DESC, id DESC`:最新记账在前)。
- //
- // 入参:ctx;f 过滤;pg 分页。
- // 出参:([]model.ExpenditureLog, total, error);空结果非 nil 由调用方保证;ErrDBError(5002)。
- //
- // 排序为什么带 `id DESC` 兜底:`create_time` 是 `datetime(3)`,同一毫秒批量导入会出现重复值,
- // 只按时间排序时**分页边界会重复/漏行**(MySQL 对相等键的排序不稳定)。加主键兜底即确定性。
- func (r *ExpenditureRepo) ListLogs(ctx context.Context, f ExpenditureLogFilter, pg PageQuery) ([]model.ExpenditureLog, int64, error) {
- db, err := r.db(ctx)
- if err != nil {
- return nil, 0, err
- }
- build := func() *gorm.DB {
- q := r.Scoped(db.Model(&model.ExpenditureLog{}))
- if f.InstitutionID > 0 {
- // 超管跨机构时用于收窄;非超管与 Scoped 同值(重复条件是等价的,只为让超管能筛)。
- q = q.Where("institution_id = ?", f.InstitutionID)
- }
- if f.CampusID != nil {
- q = q.Where("campus_id = ?", *f.CampusID)
- }
- if f.ExpenditureID != nil {
- q = q.Where("expenditure_id = ?", *f.ExpenditureID)
- }
- if f.Type != nil {
- q = q.Where("expenditure_id IN (?)", r.typeSubquery(db, *f.Type))
- }
- if f.From != nil {
- q = q.Where("create_time >= ?", *f.From)
- }
- if f.To != nil {
- // **上界是开区间**(`<`):Handler 已把"纯日期"形态补到次日 00:00
- // (`to=2026-04-10` 含当日全部流水)。若这里写成 `<=`,
- // 次日 00:00:00.000 的那条会被多算进前一天——与端点 17(账单 `to` 不含上界)同口径。
- q = q.Where("create_time < ?", *f.To)
- }
- return q
- }
- var total int64
- if err := build().Count(&total).Error; err != nil {
- return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
- }
- page, size := pg.Normalize()
- rows := make([]model.ExpenditureLog, 0, size)
- if err := build().
- Order("create_time DESC, id DESC").
- Offset(PageOffset(page, size)).Limit(size).
- Find(&rows).Error; err != nil {
- return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
- }
- return rows, total, nil
- }
- // typeSubquery 构造"某收支方向对应的字典 id 集合"子查询。
- //
- // 入参:db 查询句柄;typ 收支方向(1 支出 / 2 收入)。
- // 出参:*gorm.DB(用作 `expenditure_id IN (?)` 的子查询)。
- //
- // ⚠ 子查询里**必须**放行 `institution_id = 0`(平台通用字典),理由同文件头:
- // 只查本机构会造成"按方向筛选时,平台通用类型的记录整批消失"——比列表少一列更难查。
- // 超管(作用域机构为 0 或 SuperAdmin)不额外收窄机构:平台视角下应能看到所有机构的类型。
- func (r *ExpenditureRepo) typeSubquery(db *gorm.DB, typ int32) *gorm.DB {
- q := db.Model(&model.Expenditure{}).Select("id").Where("type = ?", typ)
- if r.Sc == nil || r.Sc.SuperAdmin {
- return q
- }
- return q.Where("institution_id = 0 OR institution_id = ?", r.Sc.InstitutionID)
- }
- // CreateLog 写入一条收支记录(**接收 tx**,仓储不开事务),并回填 DB 侧落库时间。
- //
- // 入参:ctx;tx(必传);row 记录行(InstitutionID>0、ExpenditureID>0、Cost 为非负定点字符串)。
- // 出参:nil 成功(回填自增 ID 与 `CreateTime`);ErrBadParam(2002) / ErrDBError(5002)。
- //
- // ⚠ **不写 `create_time`**:该列 `DEFAULT CURRENT_TIMESTAMP`,GORM 对"零值 + default 标签"
- // 会跳过该列、由 DB 填(本项目在 `t_courses.status` 上踩过**反向**的坑:`default:1` 让显式 0 被吞)。
- // 因此应用侧写 `time.Now()` 是错的(应用与库时钟可能差几分钟,而 `year`/`month` 是按它算的分桶)。
- //
- // ⇒ 插入后**在同一事务内回读** `create_time` 填进 `row.CreateTime`,让上层用"库认定的落库时刻"
- // 去算 `year`/`month`,而不是用应用时钟。差几秒在跨月/跨年的那一瞬间就是:
- // **"1 月 31 日 23:59:59 记的账出现在 2 月报表里"**——金额、条数、汇总全对,只有月份错,
- // 这种缺陷只能靠对账时"某月少一条、另一月多一条"发现。
- func (r *ExpenditureRepo) CreateLog(ctx context.Context, tx *gorm.DB, row *model.ExpenditureLog) error {
- if err := requireTx(tx, "CreateLog"); err != nil {
- return err
- }
- if row == nil || row.ExpenditureID <= 0 {
- return infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("repository: CreateLog 需要 expenditure_id > 0"))
- }
- if strings.TrimSpace(row.BizNo) == "" {
- return infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("repository: CreateLog 需要 biz_no"))
- }
- ctx = ctxOrBackground(ctx)
- if err := tx.WithContext(ctx).Create(row).Error; err != nil {
- return infraerrors.WithCause(infraerrors.ErrDBError, err)
- }
- // 回读落库时间(同一事务,读得到自己刚插的行)。
- var persisted model.ExpenditureLog
- if err := tx.WithContext(ctx).
- Select("create_time").
- Where("id = ?", row.ID).
- Take(&persisted).Error; err != nil {
- return infraerrors.WithCause(infraerrors.ErrDBError, err)
- }
- row.CreateTime = persisted.CreateTime
- return nil
- }
- // SetLogBuckets 回写报表分桶列 `year` / `month`(同一事务;由 Service 按落库时间算出)。
- //
- // 入参:ctx;tx(必传);id 记录 ID(>0);year 年(如 2026);month 月(1~12)。
- // 出参:nil 成功;ErrBadParam(2002) / ErrDBError(5002)。
- //
- // 单独一次 UPDATE 而不是塞进 INSERT:`create_time` 由 DB 默认值决定,INSERT 时**还没读到**它,
- // 硬把应用时钟的 y/m 写进去就是上面 `CreateLog` 注释里那个"跨月错桶"缺陷的来源。
- func (r *ExpenditureRepo) SetLogBuckets(ctx context.Context, tx *gorm.DB, id int64, year int16, month int8) error {
- if err := requireTx(tx, "SetLogBuckets"); err != nil {
- return err
- }
- if id <= 0 {
- return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: SetLogBuckets 需要 id > 0"))
- }
- if month < 1 || month > 12 {
- return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: month 必须在 1~12"))
- }
- err := tx.WithContext(ctxOrBackground(ctx)).
- Model(&model.ExpenditureLog{}).
- Where("id = ?", id).
- Updates(map[string]any{"year": year, "month": month}).Error
- if err != nil {
- return infraerrors.WithCause(infraerrors.ErrDBError, err)
- }
- return nil
- }
- // ListTypesByIDs 按 id 批量取收支字典(**含平台通用字典**)。
- //
- // 入参:ctx;ids 类型 id 集合(空 → 空切片,不发 SQL);institutionID 请求机构(0 = 只取平台字典)。
- // 出参:([]model.Expenditure, error);ErrDBError(5002)。
- //
- // 过滤口径:`institution_id = 0 OR institution_id = ?` —— `0` 是平台通用字典,**必须放行**。
- // 返回的 map 由 Service 组装,未命中(字典被删/`expenditure_id = 0`)的行保持 `type=0/name=""`,
- // **不伪造"未知类型"字符串**(那是新造业务值,契约追溯不到列)。
- func (r *ExpenditureRepo) ListTypesByIDs(ctx context.Context, ids []int64, institutionID int64) ([]model.Expenditure, error) {
- if len(ids) == 0 {
- return make([]model.Expenditure, 0), nil
- }
- db, err := r.db(ctx)
- if err != nil {
- return nil, err
- }
- rows := make([]model.Expenditure, 0, len(ids))
- if err := r.dictionaryScope(db, institutionID).Where("id IN ?", ids).Find(&rows).Error; err != nil {
- return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
- }
- return rows, nil
- }
- // ListTypes 取某机构的全部收支字典(**含平台通用字典**),按 `type, parent_id, id` 递增。
- //
- // 入参:ctx;institutionID 请求机构(0 = 只取平台字典)。
- // 出参:([]model.Expenditure, error);ErrDBError(5002)。
- //
- // 排序口径(稳定输出,便于前端做两级联动):先按 `type`(1 支出 / 2 收入,支出在前),
- // 再按 `parent_id`(顶级 0 在前),最后 `id`。SQL 里显式写序,不依赖存储顺序。
- func (r *ExpenditureRepo) ListTypes(ctx context.Context, institutionID int64) ([]model.Expenditure, error) {
- db, err := r.db(ctx)
- if err != nil {
- return nil, err
- }
- rows := make([]model.Expenditure, 0, 32)
- if err := r.dictionaryScope(db, institutionID).
- Order("type ASC, parent_id ASC, id ASC").
- Find(&rows).Error; err != nil {
- return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
- }
- return rows, nil
- }
- // GetTypeByID 取单个收支字典项(**含平台通用字典**)。
- //
- // 入参:ctx;id 类型 id(<=0 → ErrBadParam);institutionID 请求机构(0 = 只认平台字典)。
- // 出参:*model.Expenditure;ErrBadParam(2002) / ErrNotFound(3001) / ErrDBError(5002)。
- //
- // 用途(唯一):端点 60 POST 记账时校验 `expenditureId` 真实存在且对本机构可见。
- // 不存在与"别人的私有类型"**同码 3001**(不泄露存在性)。
- func (r *ExpenditureRepo) GetTypeByID(ctx context.Context, id, institutionID int64) (*model.Expenditure, error) {
- if id <= 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: 收支类型 id 必须 > 0"))
- }
- db, err := r.db(ctx)
- if err != nil {
- return nil, err
- }
- var out model.Expenditure
- qErr := r.dictionaryScope(db, institutionID).Where("id = ?", id).Take(&out).Error
- if qErr != nil {
- return nil, wrapQueryErr(qErr, "GetTypeByID(t_expenditure)")
- }
- return &out, nil
- }
- // dictionaryScope 字典表的作用域条件(**唯一实现点**:`0` = 平台通用字典必须放行)。
- //
- // 入参:db;institutionID 请求机构(<=0 = 只取平台字典)。
- // 出参:*gorm.DB(已注入 `institution_id` 条件)。
- //
- // 为什么机构为 0 时**不**写成"不过滤":超管没有机构上下文时若不过滤,会把所有机构的同名类型
- // 混在一起(`parentId` 还是各机构自己的 id),下拉框选项重复且父子关系错乱。
- // 因此超管未指定机构时**只看平台字典**,要看某机构的私有类型就显式传 `institutionId`。
- func (r *ExpenditureRepo) dictionaryScope(db *gorm.DB, institutionID int64) *gorm.DB {
- if institutionID <= 0 {
- return db.Where("institution_id = 0")
- }
- return db.Where("institution_id = 0 OR institution_id = ?", institutionID)
- }
- // db 取查询句柄(ctx 内含 tx 时复用同一事务,保证事务内读一致)。
- //
- // 入参:ctx(可 nil)。出参:(*gorm.DB, error);未装配 DB → ErrInternal。
- func (r *ExpenditureRepo) db(ctx context.Context) (*gorm.DB, error) {
- if r == nil || r.DB == nil {
- return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: ExpenditureRepo.DB 为 nil"))
- }
- if tx := TxFromContext(ctx); tx != nil {
- return tx, nil
- }
- if ctx == nil {
- return r.DB, nil
- }
- return r.DB.WithContext(ctx), nil
- }
|