| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395 |
- // 收支记录 + 收支字典服务(端点 60)——AC-13d 第三批。
- //
- // 依据:unified/03-migration/academic-frontend-contract-v1.md §2.5 行 60 / §3.4 D 表 60(v1.9 重写);
- // 设计 `academic-domain-design.md` §4.5 端点清单(角色 `super_admin, finance`)。
- //
- // ---------------------------------------------------------------------------
- // 为什么新增(AC-10 / AC-11 / AC-13c 都没覆盖过这张表)
- // ---------------------------------------------------------------------------
- //
- // 能力 现状(此前批次) 冲突
- // ListLogs `t_expenditure_log` 从未被读取 无(新只读查询)
- // ListTypes `t_expenditure` 从未被读取 无(新只读查询)
- // CreateLog 从未有过写入面(资金域的钱走 无(新事务;**不碰**
- // `t_user_expense*` 与学生钱包, `t_user_expense*`,
- // 与"机构收支台账"是两套账) 两套账不得混)
- //
- // 三条硬约束(团队批准):不改既有方法签名与行为、新增放新文件、逐条登记(上表)。
- //
- // ---------------------------------------------------------------------------
- // ⚠ 两套账不能混(这是本端点最容易犯的错)
- // ---------------------------------------------------------------------------
- //
- // 学生钱包 / 消费 → `t_user_expense` / `t_user_expense_school` / `t_user_expense_log`(端点 2~9)
- // 机构收支台账 → `t_expenditure_log`(本端点)
- //
- // 二者**没有任何列级联动**:记账不会改学生余额,学生退费也不会自动生成一条机构支出。
- // 把两者混起来算"利润"是产品口径问题,不是后端能替它决定的;本服务只做台账 CRUD。
- //
- // ---------------------------------------------------------------------------
- // 三处口径(都写在这里,避免下一批改的时候靠猜)
- // ---------------------------------------------------------------------------
- //
- // 1. **`cost` 恒存正数**,方向由字典 `type`(1 支出 / 2 收入)决定(model 列红线)。
- // ⇒ 入参 `money` 必须 > 0:负数会让"支出 + 负数"与"收入 + 正数"变成同一条记录的两种写法,
- // 报表口径立刻分叉。传 0 也拒(零元记账没有业务意义,只会污染对账)。
- // 2. **`bizNo` 由后端生成**(26 位 ULID):前端不能指定单号,否则可枚举/可撞号;
- // `biz_no` 是唯一键,撞了就是 5002(不该等到那时候才发现)。
- // 3. **`year` / `month` 分桶列按"库认定的落库时刻"算**(`create_time` 由 DB 默认值填,
- // 应用插入后回读再回写分桶)。用应用时钟会在跨月那一秒把记录记到错月份,
- // 而报表上只表现为"某月少一条"。
- package service
- import (
- "context"
- "strings"
- "time"
- "gorm.io/gorm"
- "yuansiwei/unified-backend/internal/academic/model"
- "yuansiwei/unified-backend/internal/academic/money"
- "yuansiwei/unified-backend/internal/academic/repository"
- infraerrors "yuansiwei/unified-backend/internal/infra/errors"
- appscope "yuansiwei/unified-backend/internal/infra/scope"
- )
- // 收支方向枚举(与 `t_expenditure.type` 列一致;契约 D 表:1 支出 / 2 收入)。
- const (
- // ExpenditureDirectionExpense 支出(1)。
- ExpenditureDirectionExpense int32 = 1
- // ExpenditureDirectionIncome 收入(2)。
- ExpenditureDirectionIncome int32 = 2
- )
- // ExpenditureService 机构收支台账服务(端点 60 的三条路由)。
- type ExpenditureService struct {
- // repo 收支仓储(记录走 scoping,字典按"0 = 平台通用"特判)。
- repo *repository.ExpenditureRepo
- // runner 事务运行器(记账 = 校验字典 + 插记录 + 回写分桶,必须同一事务)。
- runner *repository.TxRunner
- // sc 请求作用域(机构/校区越权断言)。
- sc *appscope.Scope
- // now 时钟(仅用于判断"入参是否给出日期"等非落库时间场景;落库时间一律取 DB)。
- now func() time.Time
- }
- // NewExpenditureService 构造收支台账服务。
- //
- // 入参:db 连接池;sc 请求作用域(nil = 系统任务视角,此时记录查询会 fail-fast)。
- // 出参:*ExpenditureService。
- func NewExpenditureService(db *gorm.DB, sc *appscope.Scope) *ExpenditureService {
- return &ExpenditureService{
- repo: repository.NewExpenditureRepo(db, sc),
- runner: repository.NewTxRunner(db),
- sc: sc,
- now: time.Now,
- }
- }
- // ExpenditureLogQuery 端点 60 GET 记录的查询入参。
- //
- // 字段:InstitutionID 机构(0 = 依赖作用域;超管可借此收窄);CampusID 校区(nil 不筛);
- // ExpenditureID 收支类型(nil 不筛);Type 方向(nil 不筛);From/To 创建时间区间(含两端);
- // Page/PageSize 分页。
- type ExpenditureLogQuery struct {
- InstitutionID int64
- CampusID *int64
- ExpenditureID *int64
- Type *int32
- From *time.Time
- To *time.Time
- Page int
- PageSize int
- }
- // ExpenditureLogView 收支记录对外视图(契约 D 表 60 的 9 个字段,**逐列溯源**)。
- //
- // 字段来源:
- //
- // ID ← t_expenditure_log.id
- // BizNo ← biz_no(业务单号,对外唯一标识;不可变)
- // ExpenditureID ← expenditure_id(**补这个字段**,否则前端无法回填编辑表单)
- // Type ← t_expenditure.type(**字典**:1 支出 / 2 收入;记录表无此列)
- // Name ← t_expenditure.name(**字典**;记录表无此列)
- // Money ← cost(金额 2 位小数;**列名是 cost、字段名是 money**,契约口径)
- // CreateUserID ← create_user_id(**记账人**,可空 → `null`;不是"收付对象",记录表没有那列)
- // CampusID ← campus_id
- // CreateTime ← create_time
- //
- // 契约里原有的 `direction` / `userId` **不下发**:前者与 `type` 同义(两表都没有该列),
- // 后者语义是"收付对象"而记录表只有"记账人"(改名 `createUserId` 才不误导)。
- type ExpenditureLogView struct {
- ID int64
- BizNo string
- ExpenditureID int64
- Type int32
- Name string
- Money money.Amount
- CreateUserID *string
- CampusID int64
- CreateTime time.Time
- }
- // ExpenditureTypeView 收支字典项(契约:`{id, type, name, parentId}`)。
- //
- // 字段来源:ID ← id;Type ← type;Name ← name;ParentID ← parent_id(0 = 顶级,自引用树)。
- type ExpenditureTypeView struct {
- ID int64
- Type int32
- Name string
- ParentID int64
- }
- // CreateExpenditureLogInput 端点 60 POST 记账入参。
- //
- // 字段:InstitutionID(必填 >0,非超管须等于作用域机构);CampusID(0 = 机构级);
- // ExpenditureID(必填 >0,须在字典中且对本机构可见);Money(必填 >0,元字符串解析后传入);
- // Remark(可空);CreateUserID(记账人 = 当前登录用户,由 Handler 从作用域取)。
- type CreateExpenditureLogInput struct {
- InstitutionID int64
- CampusID int64
- ExpenditureID int64
- Money money.Amount
- Remark string
- CreateUserID string
- }
- // CreatedExpenditureLog 记账结果(契约:POST → `{id, bizNo}`)。
- type CreatedExpenditureLog struct {
- // ID 记录 ID(自增)。
- ID int64
- // BizNo 业务单号(后端生成的 26 位 ULID)。
- BizNo string
- }
- // ListLogs 分页查询收支记录(字典名/方向由批量查字典补齐)。
- //
- // 入参:ctx;q 查询入参。
- // 出参:([]ExpenditureLogView, total, error);1004(跨机构)/5002。
- //
- // 为什么"分页取记录 + 批量取字典"而不是 SQL JOIN:记录表走 `Scoped` 生成的是**裸列名**,
- // JOIN 后 `institution_id` 会 `ambiguous column name`(本项目已踩过);
- // 且两步走能让"平台通用字典必须放行"这条规则只出现在 `ListTypesByIDs` 一处。
- func (s *ExpenditureService) ListLogs(ctx context.Context, q ExpenditureLogQuery) ([]ExpenditureLogView, int64, error) {
- if err := s.assertInstitution(q.InstitutionID); err != nil {
- return nil, 0, err
- }
- rows, total, err := s.repo.ListLogs(ctx, repository.ExpenditureLogFilter{
- InstitutionID: q.InstitutionID,
- CampusID: q.CampusID,
- ExpenditureID: q.ExpenditureID,
- Type: q.Type,
- From: q.From,
- To: q.To,
- }, repository.PageQuery{Page: q.Page, PageSize: q.PageSize})
- if err != nil {
- return nil, 0, err
- }
- if len(rows) == 0 {
- return make([]ExpenditureLogView, 0), total, nil
- }
- // 批量取字典(**含平台通用字典**),避免 N+1;`expenditure_id = 0` 不查(不存在该 id)。
- ids := make([]int64, 0, len(rows))
- seen := make(map[int64]struct{}, len(rows))
- for i := range rows {
- id := rows[i].ExpenditureID
- if id <= 0 {
- continue
- }
- if _, ok := seen[id]; ok {
- continue
- }
- seen[id] = struct{}{}
- ids = append(ids, id)
- }
- types, err := s.repo.ListTypesByIDs(ctx, ids, s.dictionaryInstitution(q.InstitutionID))
- if err != nil {
- return nil, 0, err
- }
- dict := make(map[int64]*model.Expenditure, len(types))
- for i := range types {
- dict[types[i].ID] = &types[i]
- }
- views := make([]ExpenditureLogView, 0, len(rows))
- for i := range rows {
- row := &rows[i]
- cost, perr := parseAmount(row.Cost)
- if perr != nil {
- return nil, 0, perr
- }
- view := ExpenditureLogView{
- ID: row.ID,
- BizNo: row.BizNo,
- ExpenditureID: row.ExpenditureID,
- Money: cost,
- CreateUserID: optionalTrimmedPtr(row.CreateUserID),
- CampusID: row.CampusID,
- CreateTime: row.CreateTime,
- }
- // 字典命中才有 type/name;未命中保持 0 / ""(见下)。
- if t, ok := dict[row.ExpenditureID]; ok {
- view.Type = t.Type
- view.Name = strings.TrimSpace(t.Name)
- }
- // ⚠ 字典未命中(类型被删 / `expenditure_id = 0`)时 **type=0、name=""**,不编造"未知类型":
- // 编一个字符串等于凭空造业务值(契约 §3.4 ③:每个字段必须能追到列,造的值追不到),
- // 而且前端再想按 type 分组时会被这个假值带偏。空值至少诚实。
- views = append(views, view)
- }
- return views, total, nil
- }
- // ListTypes 取收支字典(契约:`GET /admin/expenditures/types` → `types:[{id,type,name,parentId}]`)。
- //
- // 入参:ctx;institutionID 机构(<=0 = 只取平台通用字典,见 `dictionaryInstitution`)。
- // 出参:([]ExpenditureTypeView, error);1004(跨机构)/5002。
- //
- // **平台通用字典(`institution_id = 0`)恒在结果里**:它不是"未归属数据",
- // 而是全平台共用的类型(如"房租/水电/学费收入")。若被作用域过滤掉,
- // 机构视角下"新增收支"的下拉框会只剩自己建的几条(通常为 0 条)⇒ 功能像是坏了。
- func (s *ExpenditureService) ListTypes(ctx context.Context, institutionID int64) ([]ExpenditureTypeView, error) {
- if err := s.assertInstitution(institutionID); err != nil {
- return nil, err
- }
- rows, err := s.repo.ListTypes(ctx, s.dictionaryInstitution(institutionID))
- if err != nil {
- return nil, err
- }
- views := make([]ExpenditureTypeView, 0, len(rows))
- for i := range rows {
- views = append(views, ExpenditureTypeView{
- ID: rows[i].ID,
- Type: rows[i].Type,
- Name: strings.TrimSpace(rows[i].Name),
- ParentID: rows[i].ParentID,
- })
- }
- return views, nil
- }
- // CreateLog 记一笔收支(校验字典可见性 → 插记录 → 按库时间回写分桶,**同一事务**)。
- //
- // 入参:ctx;in CreateExpenditureLogInput。
- // 出参:*CreatedExpenditureLog(id + bizNo);2001(缺机构/类型)/2002(金额≤0、校区为负)/
- // 3001(收支类型不存在或不可见)/1004(跨机构/跨校区)/5002。
- func (s *ExpenditureService) CreateLog(ctx context.Context, in CreateExpenditureLogInput) (*CreatedExpenditureLog, error) {
- if in.InstitutionID <= 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("service: 记账需要 institutionId(>0)"))
- }
- if in.ExpenditureID <= 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("service: 记账需要 expenditureId(>0)"))
- }
- if in.CampusID < 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadFormat,
- errString("service: campusId 不得为负"))
- }
- if !(in.Money.GreaterThan(0)) {
- // 零/负都拒:`cost` 恒存正数,方向由字典 type 决定(model 列红线)。
- // 放行负数会让"支出 -100"与"收入 +100"成为同一条记录的两种写法,报表口径立刻分叉。
- return nil, infraerrors.WithCause(infraerrors.ErrBadFormat,
- errString("service: 记账金额必须大于 0(收支方向由 expenditureId 对应的 type 决定,"+
- "cost 恒存正数;传负数会与反方向的正数记账重复)"))
- }
- if err := in.Money.Validate(); err != nil {
- return nil, infraerrors.WithCause(infraerrors.ErrBadFormat, err)
- }
- if err := s.assertScopeMatch(in.InstitutionID, in.CampusID); err != nil {
- return nil, err
- }
- bizNo, err := newBizNo()
- if err != nil {
- return nil, err
- }
- row := &model.ExpenditureLog{
- BizNo: bizNo,
- InstitutionID: in.InstitutionID,
- CampusID: in.CampusID,
- ExpenditureID: in.ExpenditureID,
- // 金额列是 `decimal(12,2)` 字符串:走 `money.Amount.String()`(定点,恒 2 位小数)。
- Cost: in.Money.String(),
- CreateUserID: stringPtr(in.CreateUserID),
- Remark: stringPtr(in.Remark),
- }
- err = s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
- txCtx := repository.WithTxContext(ctx, tx)
- // 字典可见性校验放在事务内:并发删字典时不会出现"校验通过但字典已没了"的窗口。
- if _, terr := s.repo.GetTypeByID(txCtx, in.ExpenditureID, s.dictionaryInstitution(in.InstitutionID)); terr != nil {
- return terr // 2002(id 非法)/ 3001(不存在或不可见)/ 5002
- }
- if cerr := s.repo.CreateLog(txCtx, tx, row); cerr != nil {
- return cerr
- }
- // 分桶按**库认定的落库时刻**算(`create_time` 由 DB 默认值填,应用侧时钟会跨月错桶)。
- year := int16(row.CreateTime.Year())
- month := int8(row.CreateTime.Month())
- return s.repo.SetLogBuckets(txCtx, tx, row.ID, year, month)
- })
- if err != nil {
- return nil, err
- }
- return &CreatedExpenditureLog{ID: row.ID, BizNo: row.BizNo}, nil
- }
- // assertInstitution 机构维度越权断言(非超管必须与作用域机构一致)。
- //
- // 入参:institutionID(0 = 未指定,依赖作用域,放行)。出参:1004 或 nil。
- //
- // 为什么要单独一道(`Scoped` 已经在 SQL 层过滤):SQL 层过滤会把"跨机构查询"变成**空列表**,
- // 症状是"这家机构一条收支都没有",而不是"你越权了"。查询类端点必须显式报 1004,
- // 否则前端会以为数据丢了(本项目在 AC-13c 已按此口径统一)。
- func (s *ExpenditureService) assertInstitution(institutionID int64) error {
- if s.sc == nil {
- return infraerrors.WithCause(infraerrors.ErrInternal, errString("service: 收支服务缺少作用域"))
- }
- if s.sc.SuperAdmin || institutionID <= 0 {
- return nil
- }
- if institutionID != s.sc.InstitutionID {
- return infraerrors.WithCause(infraerrors.ErrUnscoped,
- errString("service: 收支记录机构与当前作用域机构不一致"))
- }
- return nil
- }
- // assertScopeMatch 写入时的机构 + 校区双维度断言(非超管)。
- //
- // 入参:institutionID;campusID。出参:1004 或 nil。
- //
- // 校区规则与课程域一致:作用域带校区(>0)时,写入的 `campus_id` 必须等于它,
- // 否则就是"往别的校区记账"——而收支记录是**按校区出报表**的,越权写入会直接污染他校区报表。
- func (s *ExpenditureService) assertScopeMatch(institutionID, campusID int64) error {
- if s.sc == nil {
- return infraerrors.WithCause(infraerrors.ErrInternal, errString("service: 收支服务缺少作用域"))
- }
- if s.sc.SuperAdmin {
- return nil
- }
- if institutionID != s.sc.InstitutionID {
- return infraerrors.WithCause(infraerrors.ErrUnscoped,
- errString("service: 记账机构与当前作用域机构不一致"))
- }
- if s.sc.CampusID > 0 && campusID != s.sc.CampusID {
- return infraerrors.WithCause(infraerrors.ErrUnscoped,
- errString("service: 记账校区与当前作用域校区不一致"))
- }
- return nil
- }
- // dictionaryInstitution 折算"查字典时该用哪个机构"。
- //
- // 入参:institutionID 查询/写入的机构(可 0)。出参:用于字典过滤的机构 ID(0 = 只取平台通用字典)。
- //
- // 规则:调用方给了机构就用它;没给(超管未指定)→ 用作用域机构;仍为 0 → 只取平台字典。
- // **任何一支都包含 `institution_id = 0`**(见 repo.dictionaryScope):平台通用字典永不缺席。
- func (s *ExpenditureService) dictionaryInstitution(institutionID int64) int64 {
- if institutionID > 0 {
- return institutionID
- }
- if s.sc != nil {
- return s.sc.InstitutionID
- }
- return 0
- }
|