course_service.go 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876
  1. // 课程 / 班级(排班规则)/ 排班生成服务——AC-11。
  2. //
  3. // 依据:unified/03-migration/academic-domain-design.md §4.4(端点 44~49)/ §11-3(排班盘点结论)
  4. // / §1.4(事务模板)/ §10.3(事务内调用下游一律透传 tx)/ §10.7(金额量级断言)。
  5. //
  6. // 职责边界(与 RecordService 的分工):
  7. //
  8. // CourseService 课程模板 CRUD、班级(= 排班规则 t_courses_class)CRUD、**排班生成**(端点 49)
  9. // RecordService 开始上课 / 结束上课(单事务联动)/ 打卡(端点 50~52)
  10. //
  11. // 排班生成算法(无 dump SP 覆盖,本期为旧 PHP `modules/courses/helpers` 的 Go 化最小闭环):
  12. //
  13. // 对 [from, from+Days) 的每一天 →
  14. // 对每条生效排班规则(t_courses_class.status=1)→
  15. // week 规则命中?(class.week 优先,空则回落到 courses.week;"-1" = 全部周)
  16. // 课程时间窗命中?(courses.start_time/end_time 为课程起止,超出则跳过)
  17. // 组装 start_time = 当天日期 + 规则时分;end_time = 规则下课时分(缺则按课程 duration 推)
  18. // 幂等:同 courses_id + start_time 已存在 → 跳过(**不重复插入**,AC-11 ①)
  19. //
  20. // 幂等实现(硬要点 1):DDL 无 (courses_id, start_time) 唯一键(model/testdata 指纹已锁定,本期不改 DDL),
  21. // 故采用"同一事务内先查后插"+ 本次循环内 seen 集合双重去重。并发双击仍可能双插(无唯一键兜底),
  22. // 已登记为交付遗留项(运维重跑去重 / 后续补唯一键须同步 DDL 三处)。
  23. package service
  24. import (
  25. "context"
  26. "crypto/rand"
  27. "errors"
  28. "strconv"
  29. "strings"
  30. "time"
  31. "github.com/oklog/ulid"
  32. "gorm.io/gorm"
  33. "yuansiwei/unified-backend/internal/academic/apperr"
  34. "yuansiwei/unified-backend/internal/academic/model"
  35. "yuansiwei/unified-backend/internal/academic/money"
  36. "yuansiwei/unified-backend/internal/academic/repository"
  37. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  38. appscope "yuansiwei/unified-backend/internal/infra/scope"
  39. )
  40. // 排班生成的参数边界。
  41. const (
  42. // DefaultGenerateDays 未指定天数时的默认生成窗口(一周)。
  43. DefaultGenerateDays = 7
  44. // MaxGenerateDays 单次生成窗口上限(防一次生成数十万条实例)。
  45. MaxGenerateDays = 366
  46. // MaxCourseMoneyYuan 单笔课程金额业务上限(元;§10.7 单笔量级断言,超限 → 2002)。
  47. MaxCourseMoneyYuan = "1000000.00"
  48. )
  49. // CourseService 课程/班级/排班生成服务(作用域在构造时绑定,见 scope 包注释)。
  50. type CourseService struct {
  51. // courses 课程/分类/规则/报课账户仓储(多租户)。
  52. courses *repository.CourseRepo
  53. // records 排班实例仓储(排班生成写入口 + 幂等判据)。
  54. records *repository.RecordRepo
  55. // runner 事务运行器(顶层开事务;事务内透传)。
  56. runner *repository.TxRunner
  57. // now 时钟(默认 time.Now,单测注入固定时刻)。
  58. now func() time.Time
  59. // loc 生成排班时使用的时区(默认进程本地时区;DDL 全 datetime(3))。
  60. loc *time.Location
  61. }
  62. // NewCourseService 构造课程服务(三件套共享同一 DB 句柄,避免跨连接池事务失效)。
  63. //
  64. // 入参:
  65. // - db *gorm.DB:数据库句柄(必填);
  66. // - sc *appscope.Scope:请求作用域(nil 时任何查询 panic fail-fast,系统任务须显式构造)。
  67. //
  68. // 出参:*CourseService。
  69. func NewCourseService(db *gorm.DB, sc *appscope.Scope) *CourseService {
  70. return &CourseService{
  71. courses: repository.NewCourseRepo(db, sc),
  72. records: repository.NewRecordRepo(db, sc),
  73. runner: repository.NewTxRunner(db),
  74. now: time.Now,
  75. loc: time.Local,
  76. }
  77. }
  78. // Get 取课程模板详情(端点 44/45 的读侧)。
  79. //
  80. // 入参:ctx;id 课程 ID(<=0 → 2001)。
  81. // 出参:*model.Courses;3001 不存在/不在作用域;5002 DB 异常。
  82. func (s *CourseService) Get(ctx context.Context, id int64) (*model.Courses, error) {
  83. if id <= 0 {
  84. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课程 id 必须 > 0"))
  85. }
  86. return s.courses.GetCourseByID(ctx, id)
  87. }
  88. // List 分页查询课程列表(端点 44)。
  89. //
  90. // 入参:ctx;f 过滤条件;pg 分页(仓储内钳制 pageSize ∈ [1,200])。
  91. // 出参:([]model.Courses, total int64, error)。
  92. func (s *CourseService) List(ctx context.Context, f repository.CourseFilter, pg repository.PageQuery) ([]model.Courses, int64, error) {
  93. return s.courses.ListCourses(ctx, f, pg)
  94. }
  95. // CreateCourseInput 新建课程模板入参(端点 45 POST /admin/courses)。
  96. //
  97. // 字段语义见 model.Courses 五维注释;金额一律**元字符串**(§10.6:入参/出参统一字符串)。
  98. type CreateCourseInput struct {
  99. SubjectID int32
  100. GradeID int32
  101. Type int32
  102. ClassifyID int64
  103. Name string
  104. Number int32
  105. Duration string // 单节课时长(课时,1 位小数,如 "1.5")
  106. Week string // 周规则("-1" 全部 / "0" 周日 / "1".."6" / 逗号分隔)
  107. StartTime *time.Time
  108. EndTime *time.Time
  109. Address *string
  110. OriginalCost string
  111. RealPrice string
  112. DiscountCash string
  113. DiscountRatio int32
  114. MaxPeople int32
  115. Remark *string
  116. }
  117. // CreateCourse 新建课程模板(作用域列取当前 Scope)。
  118. //
  119. // 入参:ctx;in 入参。
  120. // 出参:*model.Courses(含自增 ID);2001/2002 参数错;3001 分类不存在;5002 DB 异常。
  121. //
  122. // 校验(顺序固定,先校验后落库):
  123. // 1. name 非空、week 合法、duration > 0、number >= 0;
  124. // 2. 三个金额列可解析且不超单笔上限(original_cost/real_price >= 0;discount_cash 可为负——模型红线);
  125. // 3. classify_id > 0 时必须存在且在同一作用域(防跨租户挂分类)。
  126. func (s *CourseService) CreateCourse(ctx context.Context, in CreateCourseInput) (*model.Courses, error) {
  127. name := strings.TrimSpace(in.Name)
  128. if name == "" {
  129. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课程名称不能为空"))
  130. }
  131. if _, err := parseWeekRule(in.Week); err != nil {
  132. return nil, err
  133. }
  134. durationMinutes, err := parseLessonMinutes(in.Duration)
  135. if err != nil {
  136. return nil, err
  137. }
  138. if durationMinutes <= 0 {
  139. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 单节课时长必须 > 0"))
  140. }
  141. if in.Number < 0 {
  142. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 上课次数不能为负"))
  143. }
  144. if in.StartTime != nil && in.EndTime != nil && in.EndTime.Before(*in.StartTime) {
  145. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课程结束时间不得早于开始时间"))
  146. }
  147. originalCost, err := parseCourseMoney(in.OriginalCost, "原价", false)
  148. if err != nil {
  149. return nil, err
  150. }
  151. realPrice, err := parseCourseMoney(in.RealPrice, "实价", false)
  152. if err != nil {
  153. return nil, err
  154. }
  155. discountCash, err := parseCourseMoney(in.DiscountCash, "现金折扣", true)
  156. if err != nil {
  157. return nil, err
  158. }
  159. institutionID, campusID := s.courses.GetScopedID()
  160. if institutionID <= 0 {
  161. return nil, infraerrors.WithCause(apperr.ErrBusinessParam, errString("service: 缺少机构作用域(institution_id)"))
  162. }
  163. row := &model.Courses{
  164. SubjectID: in.SubjectID,
  165. GradeID: in.GradeID,
  166. Type: normalizeCourseType(in.Type),
  167. InstitutionID: institutionID,
  168. CampusID: campusID, // 0 = 机构级(由 SP29 回填;多校区课程永久保持 0)
  169. ClassifyID: in.ClassifyID,
  170. Status: model.CoursesStatusUnpublished,
  171. Name: name,
  172. Number: in.Number,
  173. Duration: durationFromMinutes(durationMinutes),
  174. Week: normalizeWeek(in.Week),
  175. StartTime: in.StartTime,
  176. EndTime: in.EndTime,
  177. Address: normalizeOptString(in.Address),
  178. OriginalCost: originalCost.String(),
  179. RealPrice: realPrice.String(),
  180. DiscountCash: discountCash.String(),
  181. DiscountRatio: in.DiscountRatio,
  182. Remark: normalizeOptString(in.Remark),
  183. MaxPeople: in.MaxPeople,
  184. }
  185. err = s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  186. txCtx := repository.WithTxContext(ctx, tx)
  187. if row.ClassifyID > 0 {
  188. if _, cerr := s.courses.GetClassifyByID(txCtx, row.ClassifyID); cerr != nil {
  189. return cerr // 3001:分类不存在/不在作用域
  190. }
  191. }
  192. return s.courses.CreateCourse(txCtx, tx, row)
  193. })
  194. if err != nil {
  195. return nil, err
  196. }
  197. return row, nil
  198. }
  199. // UpdateCourseInput 编辑课程模板入参(端点 45 PUT /admin/courses/:id)。
  200. //
  201. // 字段语义:nil = 不修改(部分更新);非 nil = 落库(含零值语义,如 Number=0 表示改成 0 次)。
  202. type UpdateCourseInput struct {
  203. ID int64
  204. Name *string
  205. SubjectID *int32
  206. GradeID *int32
  207. Type *int32
  208. ClassifyID *int64
  209. Number *int32
  210. Duration *string
  211. Week *string
  212. StartTime *time.Time
  213. EndTime *time.Time
  214. Address *string
  215. OriginalCost *string
  216. RealPrice *string
  217. DiscountCash *string
  218. DiscountRatio *int32
  219. MaxPeople *int32
  220. Remark *string
  221. }
  222. // UpdateCourse 部分更新课程模板(列白名单由本方法构造,不接受外部列名)。
  223. //
  224. // 入参:ctx;in 入参(ID 必填)。
  225. // 出参:*model.Courses(更新后回读);2001/2002/3001/5002。
  226. //
  227. // 红线:
  228. // - **不改** institution_id / campus_id / status(作用域与发布状态机各有专用入口);
  229. // - **不改** created_at;改 update_time/update_user_id 由 Handler 层补(当前无操作人上下文则留 NULL)。
  230. func (s *CourseService) UpdateCourse(ctx context.Context, in UpdateCourseInput) (*model.Courses, error) {
  231. if in.ID <= 0 {
  232. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课程 id 必须 > 0"))
  233. }
  234. row := &model.Courses{ID: in.ID}
  235. columns := make([]string, 0, 16)
  236. if in.Name != nil {
  237. name := strings.TrimSpace(*in.Name)
  238. if name == "" {
  239. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课程名称不能为空"))
  240. }
  241. row.Name = name
  242. columns = append(columns, "name")
  243. }
  244. if in.SubjectID != nil {
  245. row.SubjectID = *in.SubjectID
  246. columns = append(columns, "subject_id")
  247. }
  248. if in.GradeID != nil {
  249. row.GradeID = *in.GradeID
  250. columns = append(columns, "grade_id")
  251. }
  252. if in.Type != nil {
  253. row.Type = normalizeCourseType(*in.Type)
  254. columns = append(columns, "type")
  255. }
  256. if in.ClassifyID != nil {
  257. if *in.ClassifyID < 0 {
  258. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: classify_id 不能为负"))
  259. }
  260. row.ClassifyID = *in.ClassifyID
  261. columns = append(columns, "classify_id")
  262. }
  263. if in.Number != nil {
  264. if *in.Number < 0 {
  265. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 上课次数不能为负"))
  266. }
  267. row.Number = *in.Number
  268. columns = append(columns, "number")
  269. }
  270. if in.Duration != nil {
  271. minutes, err := parseLessonMinutes(*in.Duration)
  272. if err != nil {
  273. return nil, err
  274. }
  275. if minutes <= 0 {
  276. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 单节课时长必须 > 0"))
  277. }
  278. row.Duration = durationFromMinutes(minutes)
  279. columns = append(columns, "duration")
  280. }
  281. if in.Week != nil {
  282. if _, err := parseWeekRule(*in.Week); err != nil {
  283. return nil, err
  284. }
  285. row.Week = normalizeWeek(*in.Week)
  286. columns = append(columns, "week")
  287. }
  288. if in.StartTime != nil {
  289. row.StartTime = in.StartTime
  290. columns = append(columns, "start_time")
  291. }
  292. if in.EndTime != nil {
  293. row.EndTime = in.EndTime
  294. columns = append(columns, "end_time")
  295. }
  296. if in.Address != nil {
  297. row.Address = normalizeOptString(in.Address)
  298. columns = append(columns, "address")
  299. }
  300. if in.OriginalCost != nil {
  301. amt, err := parseCourseMoney(*in.OriginalCost, "原价", false)
  302. if err != nil {
  303. return nil, err
  304. }
  305. row.OriginalCost = amt.String()
  306. columns = append(columns, "original_cost")
  307. }
  308. if in.RealPrice != nil {
  309. amt, err := parseCourseMoney(*in.RealPrice, "实价", false)
  310. if err != nil {
  311. return nil, err
  312. }
  313. row.RealPrice = amt.String()
  314. columns = append(columns, "real_price")
  315. }
  316. if in.DiscountCash != nil {
  317. amt, err := parseCourseMoney(*in.DiscountCash, "现金折扣", true)
  318. if err != nil {
  319. return nil, err
  320. }
  321. row.DiscountCash = amt.String()
  322. columns = append(columns, "discount_cash")
  323. }
  324. if in.DiscountRatio != nil {
  325. row.DiscountRatio = *in.DiscountRatio
  326. columns = append(columns, "discount_ratio")
  327. }
  328. if in.MaxPeople != nil {
  329. row.MaxPeople = *in.MaxPeople
  330. columns = append(columns, "max_people")
  331. }
  332. if in.Remark != nil {
  333. row.Remark = normalizeOptString(in.Remark)
  334. columns = append(columns, "remark")
  335. }
  336. if len(columns) == 0 {
  337. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 没有任何待更新字段"))
  338. }
  339. err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  340. txCtx := repository.WithTxContext(ctx, tx)
  341. if _, cerr := s.courses.GetCourseByID(txCtx, in.ID); cerr != nil {
  342. return cerr // 3001:不存在/不在作用域
  343. }
  344. if in.ClassifyID != nil && row.ClassifyID > 0 {
  345. if _, cerr := s.courses.GetClassifyByID(txCtx, row.ClassifyID); cerr != nil {
  346. return cerr
  347. }
  348. }
  349. affected, uerr := s.courses.UpdateCourse(txCtx, tx, row, columns)
  350. if uerr != nil {
  351. return uerr
  352. }
  353. if affected == 0 {
  354. return infraerrors.WithCause(infraerrors.ErrNotFound, errString("service: 课程不存在或不在作用域"))
  355. }
  356. return nil
  357. })
  358. if err != nil {
  359. return nil, err
  360. }
  361. return s.courses.GetCourseByID(ctx, in.ID)
  362. }
  363. // ListClasses 取某课程的全部班级(排班规则)(端点 46)。
  364. //
  365. // 入参:ctx;coursesID 课程 ID(<=0 → 2001)。
  366. // 出参:[]model.CoursesClass(空结果非 nil);3001/5002。
  367. func (s *CourseService) ListClasses(ctx context.Context, coursesID int64) ([]model.CoursesClass, error) {
  368. if coursesID <= 0 {
  369. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课程 id 必须 > 0"))
  370. }
  371. // 先做作用域存在性校验:不存在的课程返回 3001,而不是空列表(避免前端误判"还没排班")
  372. if _, err := s.courses.GetCourseByID(ctx, coursesID); err != nil {
  373. return nil, err
  374. }
  375. return s.courses.ListClassesByCourses(ctx, coursesID, false)
  376. }
  377. // CreateClassInput 新建班级(排班规则)入参(端点 47)。
  378. //
  379. // 时分入参用 `"HH:MM"` 字符串(而非 datetime):旧库该两列是"时分语义",date 部分无业务含义
  380. // (model 注释红线),新写入统一 1970-01-01 + 时分,避免前端传时区导致跨天漂移。
  381. type CreateClassInput struct {
  382. CoursesID int64
  383. ClassID int64
  384. TeacherID string
  385. Week string
  386. StartTime string // "HH:MM"
  387. EndTime string // "HH:MM"(空则按课程 duration 推算)
  388. StandardLessonCost string
  389. LessonCost string
  390. DiscountCash string
  391. Number int32
  392. Address *string
  393. Remark *string
  394. }
  395. // CreateClass 新建排班规则(班级)。
  396. //
  397. // 入参:ctx;in 入参。
  398. // 出参:*model.CoursesClass(含自增 ID);2001/2002/3001/5002。
  399. //
  400. // 校验:课程存在;week 合法;时分格式合法;下课时间 > 上课时间(跨天不支持 → 2002,
  401. // 排班跨天属旧库未覆盖场景,需先补规则再实现)。
  402. func (s *CourseService) CreateClass(ctx context.Context, in CreateClassInput) (*model.CoursesClass, error) {
  403. if in.CoursesID <= 0 {
  404. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课程 id 必须 > 0"))
  405. }
  406. if _, err := parseWeekRule(in.Week); err != nil {
  407. return nil, err
  408. }
  409. startHM, err := parseHHMM(in.StartTime, "上课时间")
  410. if err != nil {
  411. return nil, err
  412. }
  413. var endHM *time.Time
  414. if strings.TrimSpace(in.EndTime) != "" {
  415. endHM, err = parseHHMM(in.EndTime, "下课时间")
  416. if err != nil {
  417. return nil, err
  418. }
  419. if !endHM.After(*startHM) {
  420. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  421. errString("service: 下课时间必须晚于上课时间(跨天排班暂不支持)"))
  422. }
  423. }
  424. if in.Number < 0 {
  425. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 开课总量不能为负"))
  426. }
  427. standardCost, err := parseCourseMoney(in.StandardLessonCost, "课程标准单价", false)
  428. if err != nil {
  429. return nil, err
  430. }
  431. lessonCost, err := parseCourseMoney(in.LessonCost, "单次课费", false)
  432. if err != nil {
  433. return nil, err
  434. }
  435. discountCash, err := parseCourseMoney(in.DiscountCash, "课程优惠", true)
  436. if err != nil {
  437. return nil, err
  438. }
  439. institutionID, campusID := s.courses.GetScopedID()
  440. if institutionID <= 0 {
  441. return nil, infraerrors.WithCause(apperr.ErrBusinessParam, errString("service: 缺少机构作用域(institution_id)"))
  442. }
  443. row := &model.CoursesClass{
  444. Status: model.CoursesClassStatusOngoing,
  445. CoursesID: in.CoursesID,
  446. ClassID: in.ClassID,
  447. TeacherID: stringPtr(in.TeacherID),
  448. InstitutionID: institutionID,
  449. CampusID: campusID,
  450. StandardLessonCost: standardCost.String(),
  451. Number: in.Number,
  452. LessonCost: lessonCost.String(),
  453. DiscountCash: discountCash.String(),
  454. FinishedNumber: 0,
  455. Confirm: model.CoursesClassConfirmNo,
  456. Week: normalizeWeek(in.Week),
  457. StartTime: startHM,
  458. EndTime: endHM,
  459. Address: normalizeOptString(in.Address),
  460. Remark: normalizeOptString(in.Remark),
  461. }
  462. err = s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  463. txCtx := repository.WithTxContext(ctx, tx)
  464. if _, cerr := s.courses.GetCourseByID(txCtx, in.CoursesID); cerr != nil {
  465. return cerr
  466. }
  467. return s.courses.CreateClass(txCtx, tx, row)
  468. })
  469. if err != nil {
  470. return nil, err
  471. }
  472. return row, nil
  473. }
  474. // GenerateRecordsInput 排班生成入参(端点 49)。
  475. //
  476. // 字段:
  477. // - CoursesID int64:课程 ID(必填);
  478. // - Days int:生成窗口天数(<=0 → 默认 7;>366 → 钳制 366);
  479. // - From *time.Time:窗口起点(nil → 服务端当前时刻所在日;只取日期部分)。
  480. type GenerateRecordsInput struct {
  481. CoursesID int64
  482. Days int
  483. From *time.Time
  484. }
  485. // GenerateRecordsResult 排班生成结果(幂等可重跑:重复调用 Created=0,SkippedExisting>0)。
  486. //
  487. // 字段:CoursesID 课程;Created 新建条数;SkippedExisting 已存在跳过条数;
  488. // SkippedRule 规则不命中(week/时间窗/缺时分)跳过条数;RecordIDs 本次新建的实例 ID(升序)。
  489. type GenerateRecordsResult struct {
  490. CoursesID int64
  491. Created int
  492. SkippedExisting int
  493. SkippedRule int
  494. RecordIDs []int64
  495. }
  496. // GenerateRecords 按 week 规则展开未来 N 天的排班实例(端点 49,AC-11 ①)。
  497. //
  498. // 入参:ctx;in 入参。
  499. // 出参:*GenerateRecordsResult;3001 课程不存在;3140 无生效排班规则;5002。
  500. //
  501. // 幂等(同 courses_id + start_time 不重复插入):
  502. // 1. 事务内 `FindByCoursesStart` 先查后插;
  503. // 2. 本轮 `seen[start]` 集合拦截"同一天同一时刻两条规则"的自撞;
  504. // 3. 重复调用只增加 SkippedExisting,不产生新行(可安全重跑,运维修复利器)。
  505. //
  506. // 事务:整批生成在**一个事务**内(部分失败不留半批数据,运维不会看到"生成了一半"的课表)。
  507. func (s *CourseService) GenerateRecords(ctx context.Context, in GenerateRecordsInput) (*GenerateRecordsResult, error) {
  508. if in.CoursesID <= 0 {
  509. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课程 id 必须 > 0"))
  510. }
  511. days := in.Days
  512. if days <= 0 {
  513. days = DefaultGenerateDays
  514. }
  515. if days > MaxGenerateDays {
  516. days = MaxGenerateDays
  517. }
  518. from := s.now()
  519. if in.From != nil && !in.From.IsZero() {
  520. from = *in.From
  521. }
  522. base := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, s.loc)
  523. out := &GenerateRecordsResult{CoursesID: in.CoursesID, RecordIDs: make([]int64, 0, days)}
  524. err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  525. txCtx := repository.WithTxContext(ctx, tx)
  526. course, cerr := s.courses.GetCourseByID(txCtx, in.CoursesID)
  527. if cerr != nil {
  528. return cerr
  529. }
  530. rules, rerr := s.courses.ListClassesByCourses(txCtx, in.CoursesID, true)
  531. if rerr != nil {
  532. return rerr
  533. }
  534. if len(rules) == 0 {
  535. return infraerrors.WithCause(apperr.ErrBusinessParam,
  536. errString("service: 该课程没有生效中的排班规则,无法生成排班"))
  537. }
  538. courseMinutes, derr := parseLessonMinutes(course.Duration)
  539. if derr != nil {
  540. return derr
  541. }
  542. seen := make(map[time.Time]struct{}, days*len(rules))
  543. for dayIdx := 0; dayIdx < days; dayIdx++ {
  544. day := base.AddDate(0, 0, dayIdx)
  545. for i := range rules {
  546. rule := &rules[i]
  547. // ① week 命中判定(class.week 优先;空串回落到课程模板的 week)
  548. weekText := rule.Week
  549. if strings.TrimSpace(weekText) == "" {
  550. weekText = course.Week
  551. }
  552. week, werr := parseWeekRule(weekText)
  553. if werr != nil {
  554. return werr
  555. }
  556. if !week.all && !week.days[int(day.Weekday())] {
  557. out.SkippedRule++
  558. continue
  559. }
  560. // ② 组装上课/下课时间(规则时分缺失 → 无法排课,跳过并注释)
  561. start, ok := combineTimeOnDay(day, rule.StartTime, s.loc)
  562. if !ok {
  563. out.SkippedRule++
  564. continue
  565. }
  566. end, ok := combineTimeOnDay(day, rule.EndTime, s.loc)
  567. if !ok {
  568. // 规则未配下课时分:按课程 duration 推(duration 也缺失 → 下课时间 = 上课时间,仍可上课)
  569. end = start.Add(time.Duration(courseMinutes) * time.Minute)
  570. }
  571. // ③ 课程时间窗(课程尚未开始 / 已结束 → 跳过)
  572. if course.StartTime != nil && start.Before(*course.StartTime) {
  573. out.SkippedRule++
  574. continue
  575. }
  576. if course.EndTime != nil && start.After(*course.EndTime) {
  577. out.SkippedRule++
  578. continue
  579. }
  580. // ④ 幂等:本轮已生成 / 库中已存在 → 跳过
  581. if _, dup := seen[start]; dup {
  582. out.SkippedExisting++
  583. continue
  584. }
  585. if _, ferr := s.records.FindByCoursesStart(txCtx, in.CoursesID, start); ferr == nil {
  586. out.SkippedExisting++
  587. continue
  588. } else if !errors.Is(ferr, infraerrors.ErrNotFound) {
  589. return ferr
  590. }
  591. bizNo, berr := newBizNo()
  592. if berr != nil {
  593. return berr
  594. }
  595. // 校区归属:规则校区 → 课程校区 → 当前作用域校区(三者皆 0 才落机构级 0)。
  596. // 落 0 的实例对"校区角色"不可见(Scoped 的 campus_id 等值过滤),故必须兜到作用域校区。
  597. campusID := pickCampusID(rule.CampusID, course.CampusID)
  598. if campusID <= 0 {
  599. _, scopeCampusID := s.courses.GetScopedID()
  600. campusID = scopeCampusID
  601. }
  602. row := &model.CoursesRecord{
  603. BizNo: bizNo,
  604. CoursesID: in.CoursesID,
  605. ClassID: rule.ClassID,
  606. TeacherID: rule.TeacherID,
  607. InstitutionID: course.InstitutionID,
  608. CampusID: campusID,
  609. Status: model.CoursesRecordStatusNotStarted,
  610. StartTime: &start,
  611. EndTime: &end,
  612. Address: normalizeOptString(rule.Address),
  613. Remark: normalizeOptString(rule.Remark),
  614. }
  615. if cerr := s.records.CreateRecord(txCtx, tx, row); cerr != nil {
  616. return cerr
  617. }
  618. seen[start] = struct{}{}
  619. out.Created++
  620. out.RecordIDs = append(out.RecordIDs, row.ID)
  621. }
  622. }
  623. return nil
  624. })
  625. if err != nil {
  626. return nil, err
  627. }
  628. return out, nil
  629. }
  630. // ---------------------------------------------------------------------------
  631. // 内部工具(课程域共用)
  632. // ---------------------------------------------------------------------------
  633. // weekRule 解析后的周规则。
  634. //
  635. // 字段:all 表示"全部周"("-1" 或空串);days[0..6] 对应 time.Weekday(0=周日)。
  636. type weekRule struct {
  637. all bool
  638. days [7]bool
  639. }
  640. // parseWeekRule 解析 week 字符串("-1"/空 = 全部周;"0".."6";逗号分隔多值)。
  641. //
  642. // 入参:week 原始字符串。出参:weekRule;非法值 → 2002(infraerrors.ErrBadParam)。
  643. //
  644. // 口径(model.Courses.Week 注释):"-1" 全部周 / "0" 周日 / "1".."6" 周一..周六 / 多值逗号分隔。
  645. // `time.Weekday` 的 0=Sunday 与旧库 "0"=周日 一致,故无需偏移(错位一天会让排班整体挪日)。
  646. func parseWeekRule(week string) (weekRule, error) {
  647. text := strings.TrimSpace(week)
  648. if text == "" || text == model.CoursesWeekAll {
  649. return weekRule{all: true}, nil
  650. }
  651. var out weekRule
  652. matched := false
  653. for _, part := range strings.Split(text, ",") {
  654. item := strings.TrimSpace(part)
  655. if item == "" {
  656. continue
  657. }
  658. day, err := strconv.Atoi(item)
  659. if err != nil || day < 0 || day > 6 {
  660. return weekRule{}, infraerrors.WithCause(infraerrors.ErrBadParam,
  661. errString("service: 非法周规则 "+text+"(仅支持 -1 / 0~6 / 逗号分隔)"))
  662. }
  663. out.days[day] = true
  664. matched = true
  665. }
  666. if !matched {
  667. return weekRule{}, infraerrors.WithCause(infraerrors.ErrBadParam,
  668. errString("service: 周规则为空 "+text))
  669. }
  670. return out, nil
  671. }
  672. // normalizeWeek 归一 week 字符串(空 → "-1";其余 trim 后原样保留)。
  673. //
  674. // 入参:week 原始值。出参:落库值(列 NOT NULL DEFAULT '-1')。
  675. func normalizeWeek(week string) string {
  676. text := strings.TrimSpace(week)
  677. if text == "" {
  678. return model.CoursesWeekAll
  679. }
  680. return text
  681. }
  682. // normalizeCourseType 归一课程类型(0 → 1000 集体班,对齐 classify.class_type 默认)。
  683. //
  684. // 入参:typ 入参类型。出参:落库类型(>0)。
  685. func normalizeCourseType(typ int32) int32 {
  686. if typ <= 0 {
  687. return 1000
  688. }
  689. return typ
  690. }
  691. // parseCourseMoney 解析课程域金额入参(元字符串 → money.Amount,含单笔上限断言)。
  692. //
  693. // 入参:raw 元字符串;label 字段中文名(错误提示);allowNegative 是否允许负数(折扣列允许)。
  694. // 出参:money.Amount;2002 解析失败/超上限/不允许的负数。
  695. //
  696. // 红线(§10.7):入参一律字符串,禁止 float64;单笔 ≤ 1,000,000.00 元(超限 = 脏数据放大前拦截)。
  697. func parseCourseMoney(raw, label string, allowNegative bool) (money.Amount, error) {
  698. text := strings.TrimSpace(raw)
  699. if text == "" {
  700. return 0, nil
  701. }
  702. amount, err := money.Parse(text)
  703. if err != nil {
  704. return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  705. errString("service: "+label+"金额格式非法 "+text))
  706. }
  707. if err := amount.Validate(); err != nil {
  708. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, err)
  709. }
  710. if amount.IsNegative() && !allowNegative {
  711. return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  712. errString("service: "+label+"不能为负"))
  713. }
  714. limit, lerr := money.Parse(MaxCourseMoneyYuan)
  715. if lerr != nil {
  716. return 0, infraerrors.WithCause(infraerrors.ErrInternal, lerr)
  717. }
  718. if amount.GreaterThan(limit) || amount.LessThan(limit.Neg()) {
  719. return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  720. errString("service: "+label+"超出单笔金额上限 "+MaxCourseMoneyYuan+" 元"))
  721. }
  722. return amount, nil
  723. }
  724. // parseLessonMinutes 解析"课时"字符串为分钟(decimal(5,1) 语义,如 "1.5" → 90 分钟,零 float64)。
  725. //
  726. // 入参:raw 课时字符串(可空 → 0)。出参:分钟数;非法格式 → 2002。
  727. //
  728. // 实现:按小数点切成 整数小时 + 小数位;小数位只支持 1~2 位(DDL 为 1 位,留 2 位兼容旧脏值),
  729. // 分钟 = frac × 60 / 10^位数(整数运算,0.25 → 15 分钟,0.5 → 30 分钟)。
  730. func parseLessonMinutes(raw string) (int, error) {
  731. text := strings.TrimSpace(raw)
  732. if text == "" {
  733. return 0, nil
  734. }
  735. parts := strings.SplitN(text, ".", 2)
  736. hours, err := strconv.Atoi(strings.TrimSpace(parts[0]))
  737. if err != nil || hours < 0 {
  738. return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  739. errString("service: 课时格式非法 "+text))
  740. }
  741. minutes := hours * 60
  742. if len(parts) == 2 {
  743. frac := strings.TrimSpace(parts[1])
  744. if frac == "" {
  745. return minutes, nil
  746. }
  747. if len(frac) > 2 {
  748. return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  749. errString("service: 课时最多 2 位小数 "+text))
  750. }
  751. value, ferr := strconv.Atoi(frac)
  752. if ferr != nil || value < 0 {
  753. return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  754. errString("service: 课时格式非法 "+text))
  755. }
  756. scale := 1
  757. for i := 0; i < len(frac); i++ {
  758. scale *= 10
  759. }
  760. minutes += value * 60 / scale
  761. }
  762. return minutes, nil
  763. }
  764. // durationFromMinutes 把分钟数还原为 decimal(5,1) 的"课时"字符串(60 分钟 → "1.0")。
  765. //
  766. // 入参:minutes 分钟数(>=0)。出参:课时字符串(保留 1 位小数,如 "1.5")。
  767. //
  768. // 说明:非 10 分钟整数倍的分钟会被**向下取整到 0.1 课时**(写入侧精度边界,由 Service 统一决定,
  769. // 避免 GORM 把 float64 写进 decimal 列产生精度噪声)。
  770. func durationFromMinutes(minutes int) string {
  771. if minutes <= 0 {
  772. return "0.0"
  773. }
  774. tenths := minutes / 6 // 6 分钟 = 0.1 课时(1 课时 = 60 分钟)
  775. return strconv.FormatInt(int64(tenths/10), 10) + "." + strconv.Itoa(tenths%10)
  776. }
  777. // parseHHMM 解析 "HH:MM" 时分(date 固定 1970-01-01,对齐 model 的"时分语义"约定)。
  778. //
  779. // 入参:raw 时分字符串;label 字段中文名。出参:*time.Time(UTC 承载 1970-01-01 HH:MM);非法 → 2002。
  780. func parseHHMM(raw, label string) (*time.Time, error) {
  781. text := strings.TrimSpace(raw)
  782. if text == "" {
  783. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  784. errString("service: "+label+"不能为空(格式 HH:MM)"))
  785. }
  786. parts := strings.Split(text, ":")
  787. if len(parts) < 2 {
  788. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  789. errString("service: "+label+"格式非法 "+text+"(应为 HH:MM)"))
  790. }
  791. hour, herr := strconv.Atoi(strings.TrimSpace(parts[0]))
  792. minute, merr := strconv.Atoi(strings.TrimSpace(parts[1]))
  793. if herr != nil || merr != nil || hour < 0 || hour > 23 || minute < 0 || minute > 59 {
  794. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  795. errString("service: "+label+"超出范围 "+text+"(00:00~23:59)"))
  796. }
  797. at := time.Date(1970, 1, 1, hour, minute, 0, 0, time.UTC)
  798. return &at, nil
  799. }
  800. // combineTimeOnDay 把"时分"叠加到指定日期上(生成排班实例的 start/end)。
  801. //
  802. // 入参:day 日期(只取年月日);hm 时分来源(nil → 返回 false);loc 时区。
  803. // 出参:(time.Time, ok);ok=false 表示时分缺失,调用方需跳过或按课程时长推算。
  804. func combineTimeOnDay(day time.Time, hm *time.Time, loc *time.Location) (time.Time, bool) {
  805. if hm == nil {
  806. return time.Time{}, false
  807. }
  808. if loc == nil {
  809. loc = time.Local
  810. }
  811. return time.Date(day.Year(), day.Month(), day.Day(), hm.Hour(), hm.Minute(), 0, 0, loc), true
  812. }
  813. // pickCampusID 取排班实例的归属校区(规则校区优先;否则课程校区)。
  814. //
  815. // 入参:ruleCampusID / courseCampusID。出参:校区 ID(可能为 0 = 机构级)。
  816. //
  817. // 说明:两处都为 0 时实例落 0,由 SP29 的课程回填 + 后续规则修正补齐(不在此处猜校区)。
  818. func pickCampusID(ruleCampusID, courseCampusID int64) int64 {
  819. if ruleCampusID > 0 {
  820. return ruleCampusID
  821. }
  822. return courseCampusID
  823. }
  824. // normalizeOptString 可选字符串归一(trim 后空串 → nil,落 DB NULL)。
  825. //
  826. // 入参:s *string(可 nil)。出参:*string(nil 或去空白后的非空指针)。
  827. func normalizeOptString(s *string) *string {
  828. if s == nil {
  829. return nil
  830. }
  831. return stringPtr(*s)
  832. }
  833. // newBizNo 生成 26 位业务单号(ULID;用于 t_courses_record / t_courses_record_student 的 biz_no)。
  834. //
  835. // 出参:(string, error);熵源故障 → ErrInternal 包装(不降级为 math/rand,防可预测单号)。
  836. func newBizNo() (string, error) {
  837. v, err := ulid.New(ulid.Timestamp(time.Now()), rand.Reader)
  838. if err != nil {
  839. return "", infraerrors.WithCause(infraerrors.ErrInternal, err)
  840. }
  841. return v.String(), nil
  842. }