| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876 |
- // 课程 / 班级(排班规则)/ 排班生成服务——AC-11。
- //
- // 依据:unified/03-migration/academic-domain-design.md §4.4(端点 44~49)/ §11-3(排班盘点结论)
- // / §1.4(事务模板)/ §10.3(事务内调用下游一律透传 tx)/ §10.7(金额量级断言)。
- //
- // 职责边界(与 RecordService 的分工):
- //
- // CourseService 课程模板 CRUD、班级(= 排班规则 t_courses_class)CRUD、**排班生成**(端点 49)
- // RecordService 开始上课 / 结束上课(单事务联动)/ 打卡(端点 50~52)
- //
- // 排班生成算法(无 dump SP 覆盖,本期为旧 PHP `modules/courses/helpers` 的 Go 化最小闭环):
- //
- // 对 [from, from+Days) 的每一天 →
- // 对每条生效排班规则(t_courses_class.status=1)→
- // week 规则命中?(class.week 优先,空则回落到 courses.week;"-1" = 全部周)
- // 课程时间窗命中?(courses.start_time/end_time 为课程起止,超出则跳过)
- // 组装 start_time = 当天日期 + 规则时分;end_time = 规则下课时分(缺则按课程 duration 推)
- // 幂等:同 courses_id + start_time 已存在 → 跳过(**不重复插入**,AC-11 ①)
- //
- // 幂等实现(硬要点 1):DDL 无 (courses_id, start_time) 唯一键(model/testdata 指纹已锁定,本期不改 DDL),
- // 故采用"同一事务内先查后插"+ 本次循环内 seen 集合双重去重。并发双击仍可能双插(无唯一键兜底),
- // 已登记为交付遗留项(运维重跑去重 / 后续补唯一键须同步 DDL 三处)。
- package service
- import (
- "context"
- "crypto/rand"
- "errors"
- "strconv"
- "strings"
- "time"
- "github.com/oklog/ulid"
- "gorm.io/gorm"
- "yuansiwei/unified-backend/internal/academic/apperr"
- "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"
- )
- // 排班生成的参数边界。
- const (
- // DefaultGenerateDays 未指定天数时的默认生成窗口(一周)。
- DefaultGenerateDays = 7
- // MaxGenerateDays 单次生成窗口上限(防一次生成数十万条实例)。
- MaxGenerateDays = 366
- // MaxCourseMoneyYuan 单笔课程金额业务上限(元;§10.7 单笔量级断言,超限 → 2002)。
- MaxCourseMoneyYuan = "1000000.00"
- )
- // CourseService 课程/班级/排班生成服务(作用域在构造时绑定,见 scope 包注释)。
- type CourseService struct {
- // courses 课程/分类/规则/报课账户仓储(多租户)。
- courses *repository.CourseRepo
- // records 排班实例仓储(排班生成写入口 + 幂等判据)。
- records *repository.RecordRepo
- // runner 事务运行器(顶层开事务;事务内透传)。
- runner *repository.TxRunner
- // now 时钟(默认 time.Now,单测注入固定时刻)。
- now func() time.Time
- // loc 生成排班时使用的时区(默认进程本地时区;DDL 全 datetime(3))。
- loc *time.Location
- }
- // NewCourseService 构造课程服务(三件套共享同一 DB 句柄,避免跨连接池事务失效)。
- //
- // 入参:
- // - db *gorm.DB:数据库句柄(必填);
- // - sc *appscope.Scope:请求作用域(nil 时任何查询 panic fail-fast,系统任务须显式构造)。
- //
- // 出参:*CourseService。
- func NewCourseService(db *gorm.DB, sc *appscope.Scope) *CourseService {
- return &CourseService{
- courses: repository.NewCourseRepo(db, sc),
- records: repository.NewRecordRepo(db, sc),
- runner: repository.NewTxRunner(db),
- now: time.Now,
- loc: time.Local,
- }
- }
- // Get 取课程模板详情(端点 44/45 的读侧)。
- //
- // 入参:ctx;id 课程 ID(<=0 → 2001)。
- // 出参:*model.Courses;3001 不存在/不在作用域;5002 DB 异常。
- func (s *CourseService) Get(ctx context.Context, id int64) (*model.Courses, error) {
- if id <= 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课程 id 必须 > 0"))
- }
- return s.courses.GetCourseByID(ctx, id)
- }
- // List 分页查询课程列表(端点 44)。
- //
- // 入参:ctx;f 过滤条件;pg 分页(仓储内钳制 pageSize ∈ [1,200])。
- // 出参:([]model.Courses, total int64, error)。
- func (s *CourseService) List(ctx context.Context, f repository.CourseFilter, pg repository.PageQuery) ([]model.Courses, int64, error) {
- return s.courses.ListCourses(ctx, f, pg)
- }
- // CreateCourseInput 新建课程模板入参(端点 45 POST /admin/courses)。
- //
- // 字段语义见 model.Courses 五维注释;金额一律**元字符串**(§10.6:入参/出参统一字符串)。
- type CreateCourseInput struct {
- SubjectID int32
- GradeID int32
- Type int32
- ClassifyID int64
- Name string
- Number int32
- Duration string // 单节课时长(课时,1 位小数,如 "1.5")
- Week string // 周规则("-1" 全部 / "0" 周日 / "1".."6" / 逗号分隔)
- StartTime *time.Time
- EndTime *time.Time
- Address *string
- OriginalCost string
- RealPrice string
- DiscountCash string
- DiscountRatio int32
- MaxPeople int32
- Remark *string
- }
- // CreateCourse 新建课程模板(作用域列取当前 Scope)。
- //
- // 入参:ctx;in 入参。
- // 出参:*model.Courses(含自增 ID);2001/2002 参数错;3001 分类不存在;5002 DB 异常。
- //
- // 校验(顺序固定,先校验后落库):
- // 1. name 非空、week 合法、duration > 0、number >= 0;
- // 2. 三个金额列可解析且不超单笔上限(original_cost/real_price >= 0;discount_cash 可为负——模型红线);
- // 3. classify_id > 0 时必须存在且在同一作用域(防跨租户挂分类)。
- func (s *CourseService) CreateCourse(ctx context.Context, in CreateCourseInput) (*model.Courses, error) {
- name := strings.TrimSpace(in.Name)
- if name == "" {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课程名称不能为空"))
- }
- if _, err := parseWeekRule(in.Week); err != nil {
- return nil, err
- }
- durationMinutes, err := parseLessonMinutes(in.Duration)
- if err != nil {
- return nil, err
- }
- if durationMinutes <= 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 单节课时长必须 > 0"))
- }
- if in.Number < 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 上课次数不能为负"))
- }
- if in.StartTime != nil && in.EndTime != nil && in.EndTime.Before(*in.StartTime) {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课程结束时间不得早于开始时间"))
- }
- originalCost, err := parseCourseMoney(in.OriginalCost, "原价", false)
- if err != nil {
- return nil, err
- }
- realPrice, err := parseCourseMoney(in.RealPrice, "实价", false)
- if err != nil {
- return nil, err
- }
- discountCash, err := parseCourseMoney(in.DiscountCash, "现金折扣", true)
- if err != nil {
- return nil, err
- }
- institutionID, campusID := s.courses.GetScopedID()
- if institutionID <= 0 {
- return nil, infraerrors.WithCause(apperr.ErrBusinessParam, errString("service: 缺少机构作用域(institution_id)"))
- }
- row := &model.Courses{
- SubjectID: in.SubjectID,
- GradeID: in.GradeID,
- Type: normalizeCourseType(in.Type),
- InstitutionID: institutionID,
- CampusID: campusID, // 0 = 机构级(由 SP29 回填;多校区课程永久保持 0)
- ClassifyID: in.ClassifyID,
- Status: model.CoursesStatusUnpublished,
- Name: name,
- Number: in.Number,
- Duration: durationFromMinutes(durationMinutes),
- Week: normalizeWeek(in.Week),
- StartTime: in.StartTime,
- EndTime: in.EndTime,
- Address: normalizeOptString(in.Address),
- OriginalCost: originalCost.String(),
- RealPrice: realPrice.String(),
- DiscountCash: discountCash.String(),
- DiscountRatio: in.DiscountRatio,
- Remark: normalizeOptString(in.Remark),
- MaxPeople: in.MaxPeople,
- }
- err = s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
- txCtx := repository.WithTxContext(ctx, tx)
- if row.ClassifyID > 0 {
- if _, cerr := s.courses.GetClassifyByID(txCtx, row.ClassifyID); cerr != nil {
- return cerr // 3001:分类不存在/不在作用域
- }
- }
- return s.courses.CreateCourse(txCtx, tx, row)
- })
- if err != nil {
- return nil, err
- }
- return row, nil
- }
- // UpdateCourseInput 编辑课程模板入参(端点 45 PUT /admin/courses/:id)。
- //
- // 字段语义:nil = 不修改(部分更新);非 nil = 落库(含零值语义,如 Number=0 表示改成 0 次)。
- type UpdateCourseInput struct {
- ID int64
- Name *string
- SubjectID *int32
- GradeID *int32
- Type *int32
- ClassifyID *int64
- Number *int32
- Duration *string
- Week *string
- StartTime *time.Time
- EndTime *time.Time
- Address *string
- OriginalCost *string
- RealPrice *string
- DiscountCash *string
- DiscountRatio *int32
- MaxPeople *int32
- Remark *string
- }
- // UpdateCourse 部分更新课程模板(列白名单由本方法构造,不接受外部列名)。
- //
- // 入参:ctx;in 入参(ID 必填)。
- // 出参:*model.Courses(更新后回读);2001/2002/3001/5002。
- //
- // 红线:
- // - **不改** institution_id / campus_id / status(作用域与发布状态机各有专用入口);
- // - **不改** created_at;改 update_time/update_user_id 由 Handler 层补(当前无操作人上下文则留 NULL)。
- func (s *CourseService) UpdateCourse(ctx context.Context, in UpdateCourseInput) (*model.Courses, error) {
- if in.ID <= 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课程 id 必须 > 0"))
- }
- row := &model.Courses{ID: in.ID}
- columns := make([]string, 0, 16)
- if in.Name != nil {
- name := strings.TrimSpace(*in.Name)
- if name == "" {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课程名称不能为空"))
- }
- row.Name = name
- columns = append(columns, "name")
- }
- if in.SubjectID != nil {
- row.SubjectID = *in.SubjectID
- columns = append(columns, "subject_id")
- }
- if in.GradeID != nil {
- row.GradeID = *in.GradeID
- columns = append(columns, "grade_id")
- }
- if in.Type != nil {
- row.Type = normalizeCourseType(*in.Type)
- columns = append(columns, "type")
- }
- if in.ClassifyID != nil {
- if *in.ClassifyID < 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: classify_id 不能为负"))
- }
- row.ClassifyID = *in.ClassifyID
- columns = append(columns, "classify_id")
- }
- if in.Number != nil {
- if *in.Number < 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 上课次数不能为负"))
- }
- row.Number = *in.Number
- columns = append(columns, "number")
- }
- if in.Duration != nil {
- minutes, err := parseLessonMinutes(*in.Duration)
- if err != nil {
- return nil, err
- }
- if minutes <= 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 单节课时长必须 > 0"))
- }
- row.Duration = durationFromMinutes(minutes)
- columns = append(columns, "duration")
- }
- if in.Week != nil {
- if _, err := parseWeekRule(*in.Week); err != nil {
- return nil, err
- }
- row.Week = normalizeWeek(*in.Week)
- columns = append(columns, "week")
- }
- if in.StartTime != nil {
- row.StartTime = in.StartTime
- columns = append(columns, "start_time")
- }
- if in.EndTime != nil {
- row.EndTime = in.EndTime
- columns = append(columns, "end_time")
- }
- if in.Address != nil {
- row.Address = normalizeOptString(in.Address)
- columns = append(columns, "address")
- }
- if in.OriginalCost != nil {
- amt, err := parseCourseMoney(*in.OriginalCost, "原价", false)
- if err != nil {
- return nil, err
- }
- row.OriginalCost = amt.String()
- columns = append(columns, "original_cost")
- }
- if in.RealPrice != nil {
- amt, err := parseCourseMoney(*in.RealPrice, "实价", false)
- if err != nil {
- return nil, err
- }
- row.RealPrice = amt.String()
- columns = append(columns, "real_price")
- }
- if in.DiscountCash != nil {
- amt, err := parseCourseMoney(*in.DiscountCash, "现金折扣", true)
- if err != nil {
- return nil, err
- }
- row.DiscountCash = amt.String()
- columns = append(columns, "discount_cash")
- }
- if in.DiscountRatio != nil {
- row.DiscountRatio = *in.DiscountRatio
- columns = append(columns, "discount_ratio")
- }
- if in.MaxPeople != nil {
- row.MaxPeople = *in.MaxPeople
- columns = append(columns, "max_people")
- }
- if in.Remark != nil {
- row.Remark = normalizeOptString(in.Remark)
- columns = append(columns, "remark")
- }
- if len(columns) == 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 没有任何待更新字段"))
- }
- err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
- txCtx := repository.WithTxContext(ctx, tx)
- if _, cerr := s.courses.GetCourseByID(txCtx, in.ID); cerr != nil {
- return cerr // 3001:不存在/不在作用域
- }
- if in.ClassifyID != nil && row.ClassifyID > 0 {
- if _, cerr := s.courses.GetClassifyByID(txCtx, row.ClassifyID); cerr != nil {
- return cerr
- }
- }
- affected, uerr := s.courses.UpdateCourse(txCtx, tx, row, columns)
- if uerr != nil {
- return uerr
- }
- if affected == 0 {
- return infraerrors.WithCause(infraerrors.ErrNotFound, errString("service: 课程不存在或不在作用域"))
- }
- return nil
- })
- if err != nil {
- return nil, err
- }
- return s.courses.GetCourseByID(ctx, in.ID)
- }
- // ListClasses 取某课程的全部班级(排班规则)(端点 46)。
- //
- // 入参:ctx;coursesID 课程 ID(<=0 → 2001)。
- // 出参:[]model.CoursesClass(空结果非 nil);3001/5002。
- func (s *CourseService) ListClasses(ctx context.Context, coursesID int64) ([]model.CoursesClass, error) {
- if coursesID <= 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课程 id 必须 > 0"))
- }
- // 先做作用域存在性校验:不存在的课程返回 3001,而不是空列表(避免前端误判"还没排班")
- if _, err := s.courses.GetCourseByID(ctx, coursesID); err != nil {
- return nil, err
- }
- return s.courses.ListClassesByCourses(ctx, coursesID, false)
- }
- // CreateClassInput 新建班级(排班规则)入参(端点 47)。
- //
- // 时分入参用 `"HH:MM"` 字符串(而非 datetime):旧库该两列是"时分语义",date 部分无业务含义
- // (model 注释红线),新写入统一 1970-01-01 + 时分,避免前端传时区导致跨天漂移。
- type CreateClassInput struct {
- CoursesID int64
- ClassID int64
- TeacherID string
- Week string
- StartTime string // "HH:MM"
- EndTime string // "HH:MM"(空则按课程 duration 推算)
- StandardLessonCost string
- LessonCost string
- DiscountCash string
- Number int32
- Address *string
- Remark *string
- }
- // CreateClass 新建排班规则(班级)。
- //
- // 入参:ctx;in 入参。
- // 出参:*model.CoursesClass(含自增 ID);2001/2002/3001/5002。
- //
- // 校验:课程存在;week 合法;时分格式合法;下课时间 > 上课时间(跨天不支持 → 2002,
- // 排班跨天属旧库未覆盖场景,需先补规则再实现)。
- func (s *CourseService) CreateClass(ctx context.Context, in CreateClassInput) (*model.CoursesClass, error) {
- if in.CoursesID <= 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课程 id 必须 > 0"))
- }
- if _, err := parseWeekRule(in.Week); err != nil {
- return nil, err
- }
- startHM, err := parseHHMM(in.StartTime, "上课时间")
- if err != nil {
- return nil, err
- }
- var endHM *time.Time
- if strings.TrimSpace(in.EndTime) != "" {
- endHM, err = parseHHMM(in.EndTime, "下课时间")
- if err != nil {
- return nil, err
- }
- if !endHM.After(*startHM) {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("service: 下课时间必须晚于上课时间(跨天排班暂不支持)"))
- }
- }
- if in.Number < 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 开课总量不能为负"))
- }
- standardCost, err := parseCourseMoney(in.StandardLessonCost, "课程标准单价", false)
- if err != nil {
- return nil, err
- }
- lessonCost, err := parseCourseMoney(in.LessonCost, "单次课费", false)
- if err != nil {
- return nil, err
- }
- discountCash, err := parseCourseMoney(in.DiscountCash, "课程优惠", true)
- if err != nil {
- return nil, err
- }
- institutionID, campusID := s.courses.GetScopedID()
- if institutionID <= 0 {
- return nil, infraerrors.WithCause(apperr.ErrBusinessParam, errString("service: 缺少机构作用域(institution_id)"))
- }
- row := &model.CoursesClass{
- Status: model.CoursesClassStatusOngoing,
- CoursesID: in.CoursesID,
- ClassID: in.ClassID,
- TeacherID: stringPtr(in.TeacherID),
- InstitutionID: institutionID,
- CampusID: campusID,
- StandardLessonCost: standardCost.String(),
- Number: in.Number,
- LessonCost: lessonCost.String(),
- DiscountCash: discountCash.String(),
- FinishedNumber: 0,
- Confirm: model.CoursesClassConfirmNo,
- Week: normalizeWeek(in.Week),
- StartTime: startHM,
- EndTime: endHM,
- Address: normalizeOptString(in.Address),
- Remark: normalizeOptString(in.Remark),
- }
- err = s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
- txCtx := repository.WithTxContext(ctx, tx)
- if _, cerr := s.courses.GetCourseByID(txCtx, in.CoursesID); cerr != nil {
- return cerr
- }
- return s.courses.CreateClass(txCtx, tx, row)
- })
- if err != nil {
- return nil, err
- }
- return row, nil
- }
- // GenerateRecordsInput 排班生成入参(端点 49)。
- //
- // 字段:
- // - CoursesID int64:课程 ID(必填);
- // - Days int:生成窗口天数(<=0 → 默认 7;>366 → 钳制 366);
- // - From *time.Time:窗口起点(nil → 服务端当前时刻所在日;只取日期部分)。
- type GenerateRecordsInput struct {
- CoursesID int64
- Days int
- From *time.Time
- }
- // GenerateRecordsResult 排班生成结果(幂等可重跑:重复调用 Created=0,SkippedExisting>0)。
- //
- // 字段:CoursesID 课程;Created 新建条数;SkippedExisting 已存在跳过条数;
- // SkippedRule 规则不命中(week/时间窗/缺时分)跳过条数;RecordIDs 本次新建的实例 ID(升序)。
- type GenerateRecordsResult struct {
- CoursesID int64
- Created int
- SkippedExisting int
- SkippedRule int
- RecordIDs []int64
- }
- // GenerateRecords 按 week 规则展开未来 N 天的排班实例(端点 49,AC-11 ①)。
- //
- // 入参:ctx;in 入参。
- // 出参:*GenerateRecordsResult;3001 课程不存在;3140 无生效排班规则;5002。
- //
- // 幂等(同 courses_id + start_time 不重复插入):
- // 1. 事务内 `FindByCoursesStart` 先查后插;
- // 2. 本轮 `seen[start]` 集合拦截"同一天同一时刻两条规则"的自撞;
- // 3. 重复调用只增加 SkippedExisting,不产生新行(可安全重跑,运维修复利器)。
- //
- // 事务:整批生成在**一个事务**内(部分失败不留半批数据,运维不会看到"生成了一半"的课表)。
- func (s *CourseService) GenerateRecords(ctx context.Context, in GenerateRecordsInput) (*GenerateRecordsResult, error) {
- if in.CoursesID <= 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课程 id 必须 > 0"))
- }
- days := in.Days
- if days <= 0 {
- days = DefaultGenerateDays
- }
- if days > MaxGenerateDays {
- days = MaxGenerateDays
- }
- from := s.now()
- if in.From != nil && !in.From.IsZero() {
- from = *in.From
- }
- base := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, s.loc)
- out := &GenerateRecordsResult{CoursesID: in.CoursesID, RecordIDs: make([]int64, 0, days)}
- err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
- txCtx := repository.WithTxContext(ctx, tx)
- course, cerr := s.courses.GetCourseByID(txCtx, in.CoursesID)
- if cerr != nil {
- return cerr
- }
- rules, rerr := s.courses.ListClassesByCourses(txCtx, in.CoursesID, true)
- if rerr != nil {
- return rerr
- }
- if len(rules) == 0 {
- return infraerrors.WithCause(apperr.ErrBusinessParam,
- errString("service: 该课程没有生效中的排班规则,无法生成排班"))
- }
- courseMinutes, derr := parseLessonMinutes(course.Duration)
- if derr != nil {
- return derr
- }
- seen := make(map[time.Time]struct{}, days*len(rules))
- for dayIdx := 0; dayIdx < days; dayIdx++ {
- day := base.AddDate(0, 0, dayIdx)
- for i := range rules {
- rule := &rules[i]
- // ① week 命中判定(class.week 优先;空串回落到课程模板的 week)
- weekText := rule.Week
- if strings.TrimSpace(weekText) == "" {
- weekText = course.Week
- }
- week, werr := parseWeekRule(weekText)
- if werr != nil {
- return werr
- }
- if !week.all && !week.days[int(day.Weekday())] {
- out.SkippedRule++
- continue
- }
- // ② 组装上课/下课时间(规则时分缺失 → 无法排课,跳过并注释)
- start, ok := combineTimeOnDay(day, rule.StartTime, s.loc)
- if !ok {
- out.SkippedRule++
- continue
- }
- end, ok := combineTimeOnDay(day, rule.EndTime, s.loc)
- if !ok {
- // 规则未配下课时分:按课程 duration 推(duration 也缺失 → 下课时间 = 上课时间,仍可上课)
- end = start.Add(time.Duration(courseMinutes) * time.Minute)
- }
- // ③ 课程时间窗(课程尚未开始 / 已结束 → 跳过)
- if course.StartTime != nil && start.Before(*course.StartTime) {
- out.SkippedRule++
- continue
- }
- if course.EndTime != nil && start.After(*course.EndTime) {
- out.SkippedRule++
- continue
- }
- // ④ 幂等:本轮已生成 / 库中已存在 → 跳过
- if _, dup := seen[start]; dup {
- out.SkippedExisting++
- continue
- }
- if _, ferr := s.records.FindByCoursesStart(txCtx, in.CoursesID, start); ferr == nil {
- out.SkippedExisting++
- continue
- } else if !errors.Is(ferr, infraerrors.ErrNotFound) {
- return ferr
- }
- bizNo, berr := newBizNo()
- if berr != nil {
- return berr
- }
- // 校区归属:规则校区 → 课程校区 → 当前作用域校区(三者皆 0 才落机构级 0)。
- // 落 0 的实例对"校区角色"不可见(Scoped 的 campus_id 等值过滤),故必须兜到作用域校区。
- campusID := pickCampusID(rule.CampusID, course.CampusID)
- if campusID <= 0 {
- _, scopeCampusID := s.courses.GetScopedID()
- campusID = scopeCampusID
- }
- row := &model.CoursesRecord{
- BizNo: bizNo,
- CoursesID: in.CoursesID,
- ClassID: rule.ClassID,
- TeacherID: rule.TeacherID,
- InstitutionID: course.InstitutionID,
- CampusID: campusID,
- Status: model.CoursesRecordStatusNotStarted,
- StartTime: &start,
- EndTime: &end,
- Address: normalizeOptString(rule.Address),
- Remark: normalizeOptString(rule.Remark),
- }
- if cerr := s.records.CreateRecord(txCtx, tx, row); cerr != nil {
- return cerr
- }
- seen[start] = struct{}{}
- out.Created++
- out.RecordIDs = append(out.RecordIDs, row.ID)
- }
- }
- return nil
- })
- if err != nil {
- return nil, err
- }
- return out, nil
- }
- // ---------------------------------------------------------------------------
- // 内部工具(课程域共用)
- // ---------------------------------------------------------------------------
- // weekRule 解析后的周规则。
- //
- // 字段:all 表示"全部周"("-1" 或空串);days[0..6] 对应 time.Weekday(0=周日)。
- type weekRule struct {
- all bool
- days [7]bool
- }
- // parseWeekRule 解析 week 字符串("-1"/空 = 全部周;"0".."6";逗号分隔多值)。
- //
- // 入参:week 原始字符串。出参:weekRule;非法值 → 2002(infraerrors.ErrBadParam)。
- //
- // 口径(model.Courses.Week 注释):"-1" 全部周 / "0" 周日 / "1".."6" 周一..周六 / 多值逗号分隔。
- // `time.Weekday` 的 0=Sunday 与旧库 "0"=周日 一致,故无需偏移(错位一天会让排班整体挪日)。
- func parseWeekRule(week string) (weekRule, error) {
- text := strings.TrimSpace(week)
- if text == "" || text == model.CoursesWeekAll {
- return weekRule{all: true}, nil
- }
- var out weekRule
- matched := false
- for _, part := range strings.Split(text, ",") {
- item := strings.TrimSpace(part)
- if item == "" {
- continue
- }
- day, err := strconv.Atoi(item)
- if err != nil || day < 0 || day > 6 {
- return weekRule{}, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("service: 非法周规则 "+text+"(仅支持 -1 / 0~6 / 逗号分隔)"))
- }
- out.days[day] = true
- matched = true
- }
- if !matched {
- return weekRule{}, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("service: 周规则为空 "+text))
- }
- return out, nil
- }
- // normalizeWeek 归一 week 字符串(空 → "-1";其余 trim 后原样保留)。
- //
- // 入参:week 原始值。出参:落库值(列 NOT NULL DEFAULT '-1')。
- func normalizeWeek(week string) string {
- text := strings.TrimSpace(week)
- if text == "" {
- return model.CoursesWeekAll
- }
- return text
- }
- // normalizeCourseType 归一课程类型(0 → 1000 集体班,对齐 classify.class_type 默认)。
- //
- // 入参:typ 入参类型。出参:落库类型(>0)。
- func normalizeCourseType(typ int32) int32 {
- if typ <= 0 {
- return 1000
- }
- return typ
- }
- // parseCourseMoney 解析课程域金额入参(元字符串 → money.Amount,含单笔上限断言)。
- //
- // 入参:raw 元字符串;label 字段中文名(错误提示);allowNegative 是否允许负数(折扣列允许)。
- // 出参:money.Amount;2002 解析失败/超上限/不允许的负数。
- //
- // 红线(§10.7):入参一律字符串,禁止 float64;单笔 ≤ 1,000,000.00 元(超限 = 脏数据放大前拦截)。
- func parseCourseMoney(raw, label string, allowNegative bool) (money.Amount, error) {
- text := strings.TrimSpace(raw)
- if text == "" {
- return 0, nil
- }
- amount, err := money.Parse(text)
- if err != nil {
- return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("service: "+label+"金额格式非法 "+text))
- }
- if err := amount.Validate(); err != nil {
- return 0, infraerrors.WithCause(infraerrors.ErrBadParam, err)
- }
- if amount.IsNegative() && !allowNegative {
- return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("service: "+label+"不能为负"))
- }
- limit, lerr := money.Parse(MaxCourseMoneyYuan)
- if lerr != nil {
- return 0, infraerrors.WithCause(infraerrors.ErrInternal, lerr)
- }
- if amount.GreaterThan(limit) || amount.LessThan(limit.Neg()) {
- return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("service: "+label+"超出单笔金额上限 "+MaxCourseMoneyYuan+" 元"))
- }
- return amount, nil
- }
- // parseLessonMinutes 解析"课时"字符串为分钟(decimal(5,1) 语义,如 "1.5" → 90 分钟,零 float64)。
- //
- // 入参:raw 课时字符串(可空 → 0)。出参:分钟数;非法格式 → 2002。
- //
- // 实现:按小数点切成 整数小时 + 小数位;小数位只支持 1~2 位(DDL 为 1 位,留 2 位兼容旧脏值),
- // 分钟 = frac × 60 / 10^位数(整数运算,0.25 → 15 分钟,0.5 → 30 分钟)。
- func parseLessonMinutes(raw string) (int, error) {
- text := strings.TrimSpace(raw)
- if text == "" {
- return 0, nil
- }
- parts := strings.SplitN(text, ".", 2)
- hours, err := strconv.Atoi(strings.TrimSpace(parts[0]))
- if err != nil || hours < 0 {
- return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("service: 课时格式非法 "+text))
- }
- minutes := hours * 60
- if len(parts) == 2 {
- frac := strings.TrimSpace(parts[1])
- if frac == "" {
- return minutes, nil
- }
- if len(frac) > 2 {
- return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("service: 课时最多 2 位小数 "+text))
- }
- value, ferr := strconv.Atoi(frac)
- if ferr != nil || value < 0 {
- return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("service: 课时格式非法 "+text))
- }
- scale := 1
- for i := 0; i < len(frac); i++ {
- scale *= 10
- }
- minutes += value * 60 / scale
- }
- return minutes, nil
- }
- // durationFromMinutes 把分钟数还原为 decimal(5,1) 的"课时"字符串(60 分钟 → "1.0")。
- //
- // 入参:minutes 分钟数(>=0)。出参:课时字符串(保留 1 位小数,如 "1.5")。
- //
- // 说明:非 10 分钟整数倍的分钟会被**向下取整到 0.1 课时**(写入侧精度边界,由 Service 统一决定,
- // 避免 GORM 把 float64 写进 decimal 列产生精度噪声)。
- func durationFromMinutes(minutes int) string {
- if minutes <= 0 {
- return "0.0"
- }
- tenths := minutes / 6 // 6 分钟 = 0.1 课时(1 课时 = 60 分钟)
- return strconv.FormatInt(int64(tenths/10), 10) + "." + strconv.Itoa(tenths%10)
- }
- // parseHHMM 解析 "HH:MM" 时分(date 固定 1970-01-01,对齐 model 的"时分语义"约定)。
- //
- // 入参:raw 时分字符串;label 字段中文名。出参:*time.Time(UTC 承载 1970-01-01 HH:MM);非法 → 2002。
- func parseHHMM(raw, label string) (*time.Time, error) {
- text := strings.TrimSpace(raw)
- if text == "" {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("service: "+label+"不能为空(格式 HH:MM)"))
- }
- parts := strings.Split(text, ":")
- if len(parts) < 2 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("service: "+label+"格式非法 "+text+"(应为 HH:MM)"))
- }
- hour, herr := strconv.Atoi(strings.TrimSpace(parts[0]))
- minute, merr := strconv.Atoi(strings.TrimSpace(parts[1]))
- if herr != nil || merr != nil || hour < 0 || hour > 23 || minute < 0 || minute > 59 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("service: "+label+"超出范围 "+text+"(00:00~23:59)"))
- }
- at := time.Date(1970, 1, 1, hour, minute, 0, 0, time.UTC)
- return &at, nil
- }
- // combineTimeOnDay 把"时分"叠加到指定日期上(生成排班实例的 start/end)。
- //
- // 入参:day 日期(只取年月日);hm 时分来源(nil → 返回 false);loc 时区。
- // 出参:(time.Time, ok);ok=false 表示时分缺失,调用方需跳过或按课程时长推算。
- func combineTimeOnDay(day time.Time, hm *time.Time, loc *time.Location) (time.Time, bool) {
- if hm == nil {
- return time.Time{}, false
- }
- if loc == nil {
- loc = time.Local
- }
- return time.Date(day.Year(), day.Month(), day.Day(), hm.Hour(), hm.Minute(), 0, 0, loc), true
- }
- // pickCampusID 取排班实例的归属校区(规则校区优先;否则课程校区)。
- //
- // 入参:ruleCampusID / courseCampusID。出参:校区 ID(可能为 0 = 机构级)。
- //
- // 说明:两处都为 0 时实例落 0,由 SP29 的课程回填 + 后续规则修正补齐(不在此处猜校区)。
- func pickCampusID(ruleCampusID, courseCampusID int64) int64 {
- if ruleCampusID > 0 {
- return ruleCampusID
- }
- return courseCampusID
- }
- // normalizeOptString 可选字符串归一(trim 后空串 → nil,落 DB NULL)。
- //
- // 入参:s *string(可 nil)。出参:*string(nil 或去空白后的非空指针)。
- func normalizeOptString(s *string) *string {
- if s == nil {
- return nil
- }
- return stringPtr(*s)
- }
- // newBizNo 生成 26 位业务单号(ULID;用于 t_courses_record / t_courses_record_student 的 biz_no)。
- //
- // 出参:(string, error);熵源故障 → ErrInternal 包装(不降级为 math/rand,防可预测单号)。
- func newBizNo() (string, error) {
- v, err := ulid.New(ulid.Timestamp(time.Now()), rand.Reader)
- if err != nil {
- return "", infraerrors.WithCause(infraerrors.ErrInternal, err)
- }
- return v.String(), nil
- }
|