| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665 |
- // 课程/排班规则/报课账户仓储(t_courses / t_courses_classify / t_courses_class / t_courses_student)
- // ——AC-11 课程域最小闭环。
- //
- // 依据:unified/03-migration/academic-domain-design.md §3.5(课程类 3 SP)/ §11-3(排班/打卡盘点结论)
- // / §1.4(事务模型)/ §1.7(scoping)/ §10.3(仓储不开事务)/ §10.4(游标 → 集合)。
- //
- // 表语义分层(详见 model/models_course.go 文件头):
- //
- // t_courses(课程模板:周规则 week + 次数 number + 单价 + 时长)
- // → t_courses_class(排班规则:某课在某班由某老师按 week + 起止时分 排课)
- // → t_courses_record(排班实例,见 record_repo.go)
- // → t_courses_student(报课订单 = 学费账户 / 课次账户,欠费核销与扣课时的作用对象)
- //
- // 覆盖的 dump 存储过程映射(**逐条对齐 dump 原文,行号为 yuansiwei_finance.sql**):
- //
- // sp_courses_update() → ListCoursesNeedingMaxPeopleBackfill + BatchSetMaxPeople(SP28,763490)
- // sp_courses_update_campusid() → ListCourseIDsWithZeroCampus + ListClassCampusIDs + SetCourseCampus(SP29,763533)
- // sp_courses_student_counteract_arrears_course() → ListArrearsCandidates
- // + IncNetReceiptsAndDecRemainderReceivable(SP27,763435)
- //
- // 作用域(设计 §10.8 A 类):四张表均带 institution_id/campus_id(NOT NULL 默认 0),
- // 因此统一继承 BaseRepo 并**每条查询**经 `r.Scoped(db)` 注入机构/校区过滤。
- // 特例说明:`t_courses.campus_id = 0` 表示"尚未归属单一校区"(由 SP29 回填),
- // 普通角色(scope.CampusID > 0)的 `campus_id = ?` 严格过滤会看不到这些待回填行——
- // 这是**有意的**(校区角色不该看到未归属课程),SP29 只允许 super_admin 执行,
- // 其 scope.SuperAdmin=true 时 Scoped 不加过滤,故回填不受影响(见 MaintenanceService 断言)。
- //
- // 事务纪律:写方法一律接收 `tx *gorm.DB`(**仓储不开事务**,边界在 Service);
- // 读方法经 db(ctx) 取句柄,ctx 内已注入事务(WithContext)时自动复用同一 tx。
- package repository
- import (
- "context"
- "strings"
- "gorm.io/gorm"
- "yuansiwei/unified-backend/internal/academic/model"
- "yuansiwei/unified-backend/internal/academic/money"
- infraerrors "yuansiwei/unified-backend/internal/infra/errors"
- appscope "yuansiwei/unified-backend/internal/infra/scope"
- )
- // CourseRepo 课程模板 + 分类 + 排班规则 + 报课账户仓储(多租户,继承 BaseRepo)。
- type CourseRepo struct {
- *BaseRepo
- }
- // NewCourseRepo 构造课程仓储。
- //
- // 入参:
- // - db *gorm.DB:连接池句柄(必填)。
- // - sc *appscope.Scope:请求作用域(系统任务可 nil,但一旦查询即 panic fail-fast,见 BaseRepo.Scoped)。
- //
- // 出参:*CourseRepo(CampusScoped=true:四张表都带 campus_id)。
- func NewCourseRepo(db *gorm.DB, sc *appscope.Scope) *CourseRepo {
- r := &CourseRepo{BaseRepo: NewBaseRepo(db, sc)}
- r.CampusScoped = true
- return r
- }
- // db 取查询句柄(ctx 内含 tx 时复用同一事务,保证事务内读一致)。
- //
- // 入参:ctx context.Context(可 nil)。出参:(*gorm.DB, error);未装配 DB → ErrInternal。
- func (r *CourseRepo) db(ctx context.Context) (*gorm.DB, error) {
- if r == nil || r.DB == nil {
- return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: CourseRepo.DB 为 nil"))
- }
- if tx := TxFromContext(ctx); tx != nil {
- return tx, nil
- }
- if ctx == nil {
- return r.DB, nil
- }
- return r.DB.WithContext(ctx), nil
- }
- // ---------------------------------------------------------------------------
- // 课程模板(t_courses)
- // ---------------------------------------------------------------------------
- // GetCourseByID 按主键取课程模板(**作用域强制**:跨机构/校区一律 ErrNotFound,不泄露存在性)。
- //
- // 入参:ctx 请求上下文;id 课程 ID(<=0 → ErrBadParam,避免无意义查询)。
- // 出参:*model.Courses;ErrNotFound(3001) / ErrBadParam(2002) / ErrDBError(5002)。
- func (r *CourseRepo) GetCourseByID(ctx context.Context, id int64) (*model.Courses, error) {
- if id <= 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: course id 必须 > 0"))
- }
- db, err := r.db(ctx)
- if err != nil {
- return nil, err
- }
- var out model.Courses
- q := r.Scoped(db.Model(&model.Courses{}))
- if err := q.Where("id = ?", id).First(&out).Error; err != nil {
- return nil, wrapQueryErr(err, "GetCourseByID(t_courses)")
- }
- return &out, nil
- }
- // CourseFilter 课程列表过滤条件(端点 44:classify/status/keyword + 分页)。
- //
- // 字段:
- // - ClassifyID int64:分类 ID(<=0 不筛);
- // - Status *int32:状态(nil 不筛;用指针以区分"未传"与"传了 0=未发布");
- // - Keyword string:名称模糊匹配(trim 后空串不筛)。
- type CourseFilter struct {
- ClassifyID int64
- Status *int32
- Keyword string
- }
- // ListCourses 分页查询课程模板(按 id 倒序,新建在前)。
- //
- // 入参:ctx;f CourseFilter;pg PageQuery(内部钳制 pageSize ∈ [1,200])。
- // 出参:([]model.Courses, total int64, error);空结果为**非 nil 空切片**(设计 §10.6)。
- func (r *CourseRepo) ListCourses(ctx context.Context, f CourseFilter, pg PageQuery) ([]model.Courses, int64, error) {
- db, err := r.db(ctx)
- if err != nil {
- return nil, 0, err
- }
- build := func() *gorm.DB {
- q := r.Scoped(db.Model(&model.Courses{}))
- if f.ClassifyID > 0 {
- q = q.Where("classify_id = ?", f.ClassifyID)
- }
- if f.Status != nil {
- q = q.Where("status = ?", *f.Status)
- }
- if kw := strings.TrimSpace(f.Keyword); kw != "" {
- // 名称模糊匹配:LIKE 通配符由参数承载(GORM 参数化,无注入面)
- q = q.Where("name LIKE ?", "%"+kw+"%")
- }
- 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.Courses, 0, size)
- if err := build().Order("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
- }
- // CreateCourse 新建课程模板(作用域列由 Service 用 GetScopedID 补齐后传入)。
- //
- // 入参:ctx;tx(**必传**);row 课程行(Name 非空;金额列已由 Service 归一为 "0.00" 形式)。
- // 出参:nil 成功(回填自增 ID);ErrBadParam / ErrDBError。
- func (r *CourseRepo) CreateCourse(ctx context.Context, tx *gorm.DB, row *model.Courses) error {
- if err := requireTx(tx, "CreateCourse"); err != nil {
- return err
- }
- if row == nil || strings.TrimSpace(row.Name) == "" {
- return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: CreateCourse 需要 name"))
- }
- if err := tx.WithContext(ctxOrBackground(ctx)).Create(row).Error; err != nil {
- return infraerrors.WithCause(infraerrors.ErrDBError, err)
- }
- return nil
- }
- // UpdateCourse 按**列白名单**更新课程模板(只更新调用方显式给出的列)。
- //
- // 入参:
- // - ctx;tx(**必传**);
- // - row 课程行(ID 必填,其余字段按 columns 选择性落库);
- // - columns 列名集合(**调用方必须用 model 列名**,非空)。
- //
- // 出参:int64 受影响行数(0 = 不存在或不在作用域内);ErrBadParam / ErrDBError。
- //
- // 说明:`Select(columns).Updates(row)` 是"部分更新"的唯一安全形状——不带 Select 会写入零值
- // (把未传字段清空),带 map 则丢失类型信息(decimal 字符串无法校验)。列名由 Service 以字面量传入,
- // 不接受外部字符串(无注入面)。
- func (r *CourseRepo) UpdateCourse(ctx context.Context, tx *gorm.DB, row *model.Courses, columns []string) (int64, error) {
- if err := requireTx(tx, "UpdateCourse"); err != nil {
- return 0, err
- }
- if row == nil || row.ID <= 0 {
- return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpdateCourse 需要 id"))
- }
- if len(columns) == 0 {
- return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpdateCourse 需要显式列清单"))
- }
- // 作用域过滤:tx 上再叠机构/校区(防跨租户改)
- q := r.Scoped(tx.WithContext(ctxOrBackground(ctx)).Model(&model.Courses{}))
- res := q.Where("id = ?", row.ID).Select(columns).Updates(row)
- if res.Error != nil {
- return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
- }
- return res.RowsAffected, nil
- }
- // ---------------------------------------------------------------------------
- // 课程分类(t_courses_classify)
- // ---------------------------------------------------------------------------
- // GetClassifyByID 按主键取课程分类(SP28 回填 max_people 的来源;课酬试算的输入)。
- //
- // 入参:ctx;id 分类 ID(<=0 → ErrBadParam)。
- // 出参:*model.CoursesClassify;ErrNotFound / ErrBadParam / ErrDBError。
- func (r *CourseRepo) GetClassifyByID(ctx context.Context, id int64) (*model.CoursesClassify, error) {
- if id <= 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: classify id 必须 > 0"))
- }
- db, err := r.db(ctx)
- if err != nil {
- return nil, err
- }
- var out model.CoursesClassify
- q := r.Scoped(db.Model(&model.CoursesClassify{}))
- if err := q.Where("id = ?", id).First(&out).Error; err != nil {
- return nil, wrapQueryErr(err, "GetClassifyByID(t_courses_classify)")
- }
- return &out, nil
- }
- // ---------------------------------------------------------------------------
- // 排班规则(t_courses_class)——「班级」在 API 层即本表
- // ---------------------------------------------------------------------------
- // ListClassesByCourses 取某课程的全部排班规则(按 id 升序,稳定顺序便于排班展开复现)。
- //
- // 入参:
- // - ctx 请求上下文;
- // - coursesID 课程 ID(<=0 → ErrBadParam);
- // - onlyActive true 表示只取 status=1(进行中)的规则(排班生成专用)。
- //
- // 出参:[]model.CoursesClass(空结果为非 nil 空切片);ErrBadParam / ErrDBError。
- func (r *CourseRepo) ListClassesByCourses(ctx context.Context, coursesID int64, onlyActive bool) ([]model.CoursesClass, error) {
- if coursesID <= 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListClassesByCourses 需要 courses_id"))
- }
- db, err := r.db(ctx)
- if err != nil {
- return nil, err
- }
- q := r.Scoped(db.Model(&model.CoursesClass{})).Where("courses_id = ?", coursesID)
- if onlyActive {
- q = q.Where("status = ?", model.CoursesClassStatusOngoing)
- }
- rows := make([]model.CoursesClass, 0, 8)
- if err := q.Order("id ASC").Find(&rows).Error; err != nil {
- return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
- }
- return rows, nil
- }
- // GetClassByID 按主键取排班规则(打卡/结束上课时取 lesson_cost / number 快照)。
- //
- // 入参:ctx;id 规则 ID(<=0 → ErrBadParam)。
- // 出参:*model.CoursesClass;ErrNotFound / ErrBadParam / ErrDBError。
- func (r *CourseRepo) GetClassByID(ctx context.Context, id int64) (*model.CoursesClass, error) {
- if id <= 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: class id 必须 > 0"))
- }
- db, err := r.db(ctx)
- if err != nil {
- return nil, err
- }
- var out model.CoursesClass
- q := r.Scoped(db.Model(&model.CoursesClass{}))
- if err := q.Where("id = ?", id).First(&out).Error; err != nil {
- return nil, wrapQueryErr(err, "GetClassByID(t_courses_class)")
- }
- return &out, nil
- }
- // CreateClass 新建排班规则(week + 起止时分 + 老师 + 课费)。
- //
- // 入参:ctx;tx(**必传**);row 规则行(CoursesID>0;Week 已由 Service 校验)。
- // 出参:nil 成功(回填 ID);ErrBadParam / ErrDBError。
- func (r *CourseRepo) CreateClass(ctx context.Context, tx *gorm.DB, row *model.CoursesClass) error {
- if err := requireTx(tx, "CreateClass"); err != nil {
- return err
- }
- if row == nil || row.CoursesID <= 0 {
- return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: CreateClass 需要 courses_id"))
- }
- if err := tx.WithContext(ctxOrBackground(ctx)).Create(row).Error; err != nil {
- return infraerrors.WithCause(infraerrors.ErrDBError, err)
- }
- return nil
- }
- // ListClassCampusIDs 取某课程排班规则涉及的**去重校区**列表(SP29 判"是否只属于单一校区")。
- //
- // 入参:ctx;coursesID 课程 ID(<=0 → ErrBadParam)。
- // 出参:[]int64 去重校区 ID(升序;空切片表示该课程尚无排班规则);ErrBadParam / ErrDBError。
- //
- // dump 原文(763546):`SELECT COUNT(*) FROM (SELECT campusid FROM t_courses_class WHERE coursesid=logId
- // GROUP BY campusid) t` → 只关心"去重后的校区个数",故等价改写为 DISTINCT 列表(集合化,§10.4)。
- func (r *CourseRepo) ListClassCampusIDs(ctx context.Context, coursesID int64) ([]int64, error) {
- if coursesID <= 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListClassCampusIDs 需要 courses_id"))
- }
- db, err := r.db(ctx)
- if err != nil {
- return nil, err
- }
- var ids []int64
- q := r.Scoped(db.Model(&model.CoursesClass{})).Where("courses_id = ?", coursesID)
- if err := q.Distinct().Order("campus_id ASC").Pluck("campus_id", &ids).Error; err != nil {
- return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
- }
- if ids == nil {
- ids = make([]int64, 0)
- }
- return ids, nil
- }
- // ---------------------------------------------------------------------------
- // SP28 数据修复:max_people 从分类继承(dump 763490 sp_courses_update)
- // ---------------------------------------------------------------------------
- // MaxPeopleBackfillRow 待回填行(课程 ID + 其分类的 max_people)。
- //
- // 字段:CourseID 课程 ID;MaxPeople 分类表的目标值(0 表示分类也未配置 → 回填后仍为 0,无实际变化)。
- type MaxPeopleBackfillRow struct {
- CourseID int64 `gorm:"column:course_id"`
- MaxPeople int32 `gorm:"column:max_people"`
- }
- // ListCoursesNeedingMaxPeopleBackfill 取 `max_people = 0` 的课程及其分类的 max_people。
- //
- // 入参:ctx 请求上下文。
- // 出参:[]MaxPeopleBackfillRow(按 course_id 升序 —— 对齐 dump 游标 `ORDER BY t1.id`);ErrDBError。
- //
- // dump 原文(763492):
- //
- // SELECT t1.id, t2.max_people FROM t_courses t1 LEFT JOIN t_courses_classify t2
- // ON t1.classifyid = t2.id WHERE t1.max_people = 0 ORDER BY t1.id;
- //
- // ⚠ LEFT JOIN 语义(**不可改 INNER JOIN**):分类缺失时 t2.max_people 为 NULL → dump 的
- // `UPDATE t_courses SET max_people = NULL` 在有 NOT NULL 约束的新表上会失败;目标态该列
- // `NOT NULL DEFAULT 0`,故分类缺失归为 0("无分类可继承"= 保持 0,与 INNER JOIN 的跳过等价,
- // 但**行仍计入扫描数**,便于运维核对)。
- //
- // 实现说明(**两查而非 SQL JOIN**):两张表都带 institution_id/campus_id,JOIN 后
- // `Scoped` 注入的裸列名 `institution_id` 会变成 ambiguous column(MySQL/SQLite 均报错),
- // 而 Scoped 不接受表别名。改为"先取课程行 → 再按 classify_id 批量取分类"(2 条索引查询、
- // 无 N+1),结果与 LEFT JOIN 等价(缺失分类 → 0)。
- func (r *CourseRepo) ListCoursesNeedingMaxPeopleBackfill(ctx context.Context) ([]MaxPeopleBackfillRow, error) {
- db, err := r.db(ctx)
- if err != nil {
- return nil, err
- }
- type courseRow struct {
- CourseID int64 `gorm:"column:id"`
- ClassifyID int64 `gorm:"column:classify_id"`
- }
- courses := make([]courseRow, 0, 32)
- q := r.Scoped(db.Model(&model.Courses{})).Select("id, classify_id").Where("max_people = 0").Order("id ASC")
- if err := q.Find(&courses).Error; err != nil {
- return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
- }
- classifyIDs := make([]int64, 0, len(courses))
- for i := range courses {
- if courses[i].ClassifyID > 0 {
- classifyIDs = append(classifyIDs, courses[i].ClassifyID)
- }
- }
- classifyMax := make(map[int64]int32, len(classifyIDs))
- if len(classifyIDs) > 0 {
- type classifyRow struct {
- ID int64 `gorm:"column:id"`
- MaxPeople int32 `gorm:"column:max_people"`
- }
- rows := make([]classifyRow, 0, len(classifyIDs))
- cq := r.Scoped(db.Model(&model.CoursesClassify{})).Select("id, max_people").Where("id IN ?", dedupPositive(classifyIDs))
- if err := cq.Find(&rows).Error; err != nil {
- return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
- }
- for i := range rows {
- classifyMax[rows[i].ID] = rows[i].MaxPeople
- }
- }
- out := make([]MaxPeopleBackfillRow, 0, len(courses))
- for i := range courses {
- target := classifyMax[courses[i].ClassifyID] // 缺失/无分类 → 0(保持 0,等价 dump 的 NULL 归一)
- out = append(out, MaxPeopleBackfillRow{CourseID: courses[i].CourseID, MaxPeople: target})
- }
- return out, nil
- }
- // BatchSetMaxPeople 批量回填 max_people(同一目标值的课程一次 UPDATE,`WHERE id IN (...)`)。
- //
- // 入参:ctx;tx(**必传**);maxPeople 目标值(>0 才有意义,Service 保证);courseIDs 课程 ID 集合。
- // 出参:int64 受影响行数;ErrBadParam / ErrDBError。
- //
- // 集合化说明(§10.4):dump 是游标逐行 `UPDATE ... WHERE id = curId`;同一 max_people 值的一组
- // 课程终态与逐行更新等价,故按值分组一次 UPDATE(组内 ORDER BY 已在外层 SELECT 保证,
- // **分配顺序不变**)。`max_people = 0` 的组由 Service 跳过(写 0 等于不改,RowsAffected 会误导运维)。
- func (r *CourseRepo) BatchSetMaxPeople(ctx context.Context, tx *gorm.DB, maxPeople int32, courseIDs []int64) (int64, error) {
- if err := requireTx(tx, "BatchSetMaxPeople"); err != nil {
- return 0, err
- }
- if maxPeople <= 0 {
- return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: BatchSetMaxPeople 需要 max_people > 0"))
- }
- ids := dedupPositive(courseIDs)
- if len(ids) == 0 {
- return 0, nil
- }
- q := r.Scoped(tx.WithContext(ctxOrBackground(ctx)).Model(&model.Courses{}))
- res := q.Where("id IN ?", ids).UpdateColumn("max_people", maxPeople)
- if res.Error != nil {
- return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
- }
- return res.RowsAffected, nil
- }
- // ---------------------------------------------------------------------------
- // SP29 数据修复:campus_id 回填(dump 763533 sp_courses_update_campusid)
- // ---------------------------------------------------------------------------
- // ListCourseIDsWithZeroCampus 取 `campus_id = 0`(未归属单一校区)的课程 ID。
- //
- // 入参:ctx 请求上下文。
- // 出参:[]int64(升序;本方法**刻意加** ORDER BY —— dump 游标无 ORDER BY,行序不保证;
- // 回填结果与行序无关(每行独立判定),加排序只为让重跑日志可比对、可复现);ErrDBError。
- func (r *CourseRepo) ListCourseIDsWithZeroCampus(ctx context.Context) ([]int64, error) {
- db, err := r.db(ctx)
- if err != nil {
- return nil, err
- }
- var ids []int64
- q := r.Scoped(db.Model(&model.Courses{})).Where("campus_id = 0")
- if err := q.Order("id ASC").Pluck("id", &ids).Error; err != nil {
- return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
- }
- if ids == nil {
- ids = make([]int64, 0)
- }
- return ids, nil
- }
- // SetCourseCampus 回填课程归属校区(仅当 Service 判定"该课程只属于 1 个校区"时调用)。
- //
- // 入参:ctx;tx(**必传**);id 课程 ID(>0);campusID 目标校区(>0)。
- // 出参:int64 受影响行数(0 = 并发下已被回填或不在作用域);ErrBadParam / ErrDBError。
- //
- // 红线:**不得**在 campus_count != 1 时调用(多校区课程保持 0,dump 763552 的 IF 分支);
- // WHERE 同时限 `campus_id = 0` 以防并发回填覆盖人工指定值(幂等)。
- func (r *CourseRepo) SetCourseCampus(ctx context.Context, tx *gorm.DB, id, campusID int64) (int64, error) {
- if err := requireTx(tx, "SetCourseCampus"); err != nil {
- return 0, err
- }
- if id <= 0 || campusID <= 0 {
- return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: SetCourseCampus 需要 id 与 campus_id 均 > 0"))
- }
- q := r.Scoped(tx.WithContext(ctxOrBackground(ctx)).Model(&model.Courses{}))
- res := q.Where("id = ? AND campus_id = 0", id).UpdateColumn("campus_id", campusID)
- if res.Error != nil {
- return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
- }
- return res.RowsAffected, nil
- }
- // ---------------------------------------------------------------------------
- // 报课账户(t_courses_student):SP27 欠费核销 + 结束上课扣课时
- // ---------------------------------------------------------------------------
- // ArrearsCandidate 欠费核销候选行(报课记录 ID + 剩余应收)。
- //
- // 字段:
- // - StudentCourseID int64:t_courses_student.id;
- // - RemainderReceivable string:剩余应收(**元字符串**;sqlite NUMERIC 回读不能直接落 money.Amount,
- // 由 Service 用 money.Parse 转分,口径与全仓一致)。
- type ArrearsCandidate struct {
- StudentCourseID int64 `gorm:"column:student_course_id"`
- RemainderReceivable string `gorm:"column:remainder_receivable"`
- }
- // ListArrearsCandidates 取某学生在本机构/校区的欠费报课列表(SP27 游标集合化)。
- //
- // 入参:ctx;studentID 学生(ULID);institutionID/campusID 作用域(均 >0,Service 已校验)。
- //
- // 出参:[]ArrearsCandidate(按"课程开课时分升序,其次报课 id 升序");ErrBadParam / ErrDBError。
- //
- // dump 原文(763446,**逐字对齐**):
- //
- // SELECT t1.id, t1.remainder_receivable FROM t_courses_student t1
- // INNER JOIN t_courses_class t2 ON t1.coursesid=t2.coursesid AND t1.classid=t2.classid
- // WHERE t1.studentid=inputStudentid AND t1.schoolid=inputSchoolid AND t1.campusid=inputCampusid
- // AND t1.remainder_receivable>0 AND t1.confirm_discount_userid>0
- // ORDER BY t2.start_time ASC;
- //
- // 两处**有意归一**(均已登记交付说明,评审重点):
- // 1. `confirm_discount_userid > 0`:新模型该列是 ULID 字符串(*string),"有一个确认操作人"的
- // 等价判据是 `IS NOT NULL AND <> ”`;**NULL/空串 = 不可充值**(模型红线),故不能用 0 替代。
- // 2. `INNER JOIN t_courses_class` 是 1:N —— 同一 (courses_id, class_id) 有多条排班规则时会把
- // **同一报课行**重复取出;旧 SP 游标物化快照后逐行核销,第二行会按"旧剩余应收"再核销一次
- // → net_receipts 被重复累加(dump 缺陷,同类于 A9 的不递减类问题)。Go 侧改为
- // `MIN(start_time)` 聚合子查询:**同一报课行只出现一次**,排序依据仍是课程开课时分(顺序不变)。
- func (r *CourseRepo) ListArrearsCandidates(ctx context.Context, studentID string, institutionID, campusID int64) ([]ArrearsCandidate, error) {
- if strings.TrimSpace(studentID) == "" {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListArrearsCandidates 需要 student_id"))
- }
- if institutionID <= 0 || campusID <= 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("repository: ListArrearsCandidates 需要 institution_id 与 campus_id 均 > 0"))
- }
- db, err := r.db(ctx)
- if err != nil {
- return nil, err
- }
- const firstStartSub = "SELECT courses_id, class_id, MIN(start_time) AS first_start " +
- "FROM t_courses_class GROUP BY courses_id, class_id"
- rows := make([]ArrearsCandidate, 0, 8)
- q := r.Scoped(db.Table("t_courses_student AS t1")).
- Select("t1.id AS student_course_id, t1.remainder_receivable AS remainder_receivable").
- Joins("INNER JOIN ("+firstStartSub+") AS t2 ON t1.courses_id = t2.courses_id AND t1.class_id = t2.class_id").
- Where("t1.student_id = ?", strings.TrimSpace(studentID)).
- Where("t1.institution_id = ? AND t1.campus_id = ?", institutionID, campusID).
- Where("t1.remainder_receivable > 0").
- Where("t1.confirm_discount_user_id IS NOT NULL AND t1.confirm_discount_user_id <> ''").
- Order("t2.first_start ASC, t1.id ASC")
- if err := q.Find(&rows).Error; err != nil {
- return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
- }
- return rows, nil
- }
- // IncNetReceiptsAndDecRemainderReceivable 欠费核销落账(`net_receipts + amount`、`remainder_receivable - amount`)。
- //
- // 入参:ctx;tx(**必传**);studentCourseID 报课记录 ID(>0);amount 本次核销金额(>0)。
- // 出参:int64 受影响行数(0 = 行不存在/不在作用域);ErrBadParam / ErrDBError。
- //
- // dump 原文(763476):
- //
- // UPDATE t_courses_student SET net_receipts = net_receipts + remainderReceivable,
- // remainder_receivable = remainder_receivable - remainderReceivable WHERE id = stuCourseid;
- //
- // 红线:两列**同一条 UPDATE**(拆两条会出现"实收加了、应收没减"的中间态);
- // `unpaid` 列 dump **不动**(§3.5 表格文字写 unpaid>0,与 dump 原文不一致,以 dump 为准)。
- func (r *CourseRepo) IncNetReceiptsAndDecRemainderReceivable(ctx context.Context, tx *gorm.DB, studentCourseID int64, amount money.Amount) (int64, error) {
- if err := requireTx(tx, "IncNetReceiptsAndDecRemainderReceivable"); err != nil {
- return 0, err
- }
- if studentCourseID <= 0 {
- return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: 核销需要 t_courses_student.id"))
- }
- if !amount.GreaterThan(0) {
- return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: 核销金额必须 > 0"))
- }
- if err := amount.Validate(); err != nil {
- return 0, infraerrors.WithCause(infraerrors.ErrBadParam, err)
- }
- q := r.Scoped(tx.WithContext(ctxOrBackground(ctx)).Model(&model.CoursesStudent{}))
- res := q.Where("id = ?", studentCourseID).Updates(map[string]any{
- "net_receipts": gorm.Expr("net_receipts + ?", amount.String()),
- "remainder_receivable": gorm.Expr("remainder_receivable - ?", amount.String()),
- })
- if res.Error != nil {
- return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
- }
- return res.RowsAffected, nil
- }
- // ListActiveStudentsByCourses 取某课程**在读**报课学生(结束上课时"未显式打卡则按在读名单全勤")。
- //
- // 入参:ctx;coursesID 课程 ID(<=0 → ErrBadParam)。
- // 出参:[]model.CoursesStudent(按 id 升序 —— 固定顺序保证扣课时与分账的**锁序可复现**);
- // ErrBadParam / ErrDBError。
- func (r *CourseRepo) ListActiveStudentsByCourses(ctx context.Context, coursesID int64) ([]model.CoursesStudent, error) {
- if coursesID <= 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListActiveStudentsByCourses 需要 courses_id"))
- }
- db, err := r.db(ctx)
- if err != nil {
- return nil, err
- }
- rows := make([]model.CoursesStudent, 0, 16)
- q := r.Scoped(db.Model(&model.CoursesStudent{})).
- Where("courses_id = ?", coursesID).
- Where("status = ?", model.CoursesStudentStatusNormal)
- if err := q.Order("id ASC").Find(&rows).Error; err != nil {
- return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
- }
- return rows, nil
- }
- // GetStudentCourse 按(课程 + 学生)取在读报课记录(扣课时前锁定扣减基数)。
- //
- // 入参:ctx;coursesID 课程 ID(>0);studentID 学生(ULID,非空)。
- // 出参:*model.CoursesStudent;ErrNotFound(未报课)/ ErrBadParam / ErrDBError。
- //
- // 说明:同一学生对同一课程理论上一条在读记录;按 id 升序取第一条(多行脏数据不阻断流程,
- // 由运维按报表修复)。
- func (r *CourseRepo) GetStudentCourse(ctx context.Context, coursesID int64, studentID string) (*model.CoursesStudent, error) {
- if coursesID <= 0 || strings.TrimSpace(studentID) == "" {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: GetStudentCourse 需要 courses_id/student_id"))
- }
- db, err := r.db(ctx)
- if err != nil {
- return nil, err
- }
- var out model.CoursesStudent
- q := r.Scoped(db.Model(&model.CoursesStudent{})).Where("courses_id = ? AND student_id = ?", coursesID, studentID)
- if err := q.Order("id ASC").First(&out).Error; err != nil {
- return nil, wrapQueryErr(err, "GetStudentCourse(t_courses_student)")
- }
- return &out, nil
- }
- // ApplyLessonDeduction 扣课时:`finished_number + 1`、`remainder_number - 1`(下限 0)、`remainder_cost - cost`。
- //
- // 入参:ctx;tx(**必传**);studentCourseID 报课记录 ID(>0);cost 本次课费(>=0;0 表示赠送/免费)。
- // 出参:int64 受影响行数;ErrBadParam / ErrDBError。
- //
- // 语义(无 dump 依据 —— 本期为 PHP 逻辑的 Go 化最小闭环,设计 §11-3):
- // - `finished_number` 只增(打卡计数);
- // - `remainder_number` 用 `CASE WHEN > 0 THEN -1 ELSE 0` 兜底,避免脏数据把剩余课次刷成负数;
- // - `remainder_cost` **不做下限截断**:负数即"课时已用超"的账实不符信号,必须暴露而非静默抹平
- // (与 remainder_number 的取舍不同:课次是计数、费用是金额,金额静默截断会破坏对账恒等式)。
- func (r *CourseRepo) ApplyLessonDeduction(ctx context.Context, tx *gorm.DB, studentCourseID int64, cost money.Amount) (int64, error) {
- if err := requireTx(tx, "ApplyLessonDeduction"); err != nil {
- return 0, err
- }
- if studentCourseID <= 0 {
- return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ApplyLessonDeduction 需要 t_courses_student.id"))
- }
- if cost.IsNegative() {
- return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: 扣课金额不能为负"))
- }
- if err := cost.Validate(); err != nil {
- return 0, infraerrors.WithCause(infraerrors.ErrBadParam, err)
- }
- fields := map[string]any{
- "finished_number": gorm.Expr("finished_number + 1"),
- "remainder_number": gorm.Expr("CASE WHEN remainder_number > 0 THEN remainder_number - 1 ELSE 0 END"),
- }
- if !cost.IsZero() {
- fields["remainder_cost"] = gorm.Expr("remainder_cost - ?", cost.String())
- }
- q := r.Scoped(tx.WithContext(ctxOrBackground(ctx)).Model(&model.CoursesStudent{}))
- res := q.Where("id = ?", studentCourseID).Updates(fields)
- if res.Error != nil {
- return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
- }
- return res.RowsAffected, nil
- }
- // dedupPositive 去重并剔除非正数(批量 UPDATE 的 IN 列表净化)。
- //
- // 入参:ids int64 集合(可含重复/0/负数)。出参:去重后的正数切片(保持首次出现顺序)。
- func dedupPositive(ids []int64) []int64 {
- out := make([]int64, 0, len(ids))
- seen := make(map[int64]struct{}, len(ids))
- for _, id := range ids {
- if id <= 0 {
- continue
- }
- if _, ok := seen[id]; ok {
- continue
- }
- seen[id] = struct{}{}
- out = append(out, id)
- }
- return out
- }
|