| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324 |
- // 课程域**补充服务**(端点 44~57 的读装配 / 报名写入 / 结课响应装配)——AC-13c 追加。
- //
- // 依据:unified/03-migration/academic-frontend-contract-v1.md §2.4(端点表)/
- // §3.4 C 表(44~57 字段级定义)/ §3.4 总原则 ③「字段准入条件」/ §3.8-E4(`unpaid` 废弃列);
- // 设计 `academic-domain-design.md` §3.5(SP27 欠费核销)/ §4.4(端点清单)。
- //
- // ---------------------------------------------------------------------------
- // 本文件与 AC-11 的边界(**只新增,不改既有逻辑**)
- // ---------------------------------------------------------------------------
- //
- // AC-11 交付的 `course_service.go` / `record_service.go` / `maintenance_service.go` **一行未改**。
- // 契约要求的下列能力在 AC-11 没有对应方法,故在此**新增**:
- //
- // 端点 44 ListCourseRows teacherIds(t_courses_class)+ recordCount(t_courses_record)
- // 端点 46 ListClassRows week 原值下发(**不改写成 1~7 数字**,理由见 ClassRow.Week)
- // 端点 47 AssertNoClassConflict 冲突 → 3122(AC-11 的 CreateClass 无冲突判定)
- // 端点 53 ListStudentRows 报课学生分页(AC-11 只有"取全部在读")
- // 端点 54 Enroll 报课写入(AC-11 无报课入口)
- // 端点 55 ListArrearsRows 跨课程欠费分页(AC-11 只有"按单学生取候选")
- // 端点 57 ListScheduleRows 学生课表(record ⨝ courses_student)
- // 端点 51 RecordViewService 结课响应装配(divide[] / payroll 溯源列,**只读、不进结账事务**)
- //
- // ⚠ 这些是"补 handler 所需的数据能力",不是"修 AC-11 的 bug":AC-11 的服务方法本身
- // 在其声明范围内行为正确(已由 `course_service_test.go` / `record_service_test.go` 锁定)。
- //
- // ---------------------------------------------------------------------------
- // 三条量纲/语义红线(本文件逐条落地)
- // ---------------------------------------------------------------------------
- //
- // 1. 金额一律 `money.Amount` 或两位小数字符串,**不出现 float64**;
- // 2. 欠费判据**只有** `remainder_receivable`(`unpaid` 是 2018-05 停更的僵尸列,禁读禁判);
- // 3. 时间窗 `from` 左闭 / `to` 右开(契约 §1.3)。
- package service
- import (
- "context"
- "strconv"
- "strings"
- "time"
- "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"
- )
- // ---------------------------------------------------------------------------
- // 端点 44:课程列表(含 teacherIds / recordCount)
- // ---------------------------------------------------------------------------
- // CourseRow 端点 44 的行(契约 §3.4 C 行 44:`id/institutionId/name/classify/status/teacherIds/recordCount`)。
- //
- // 字段来源(**逐字段列溯源**,契约 §3.4 ③):
- //
- // ID ← t_courses.id
- // InstitutionID ← t_courses.institution_id
- // Name ← t_courses.name
- // ClassifyID ← t_courses.classify_id(契约名 `classify`)
- // Status ← t_courses.status
- // TeacherIDs ← t_courses_class.teacher_id(去重、升序;空 → [])
- // RecordCount ← COUNT(t_courses_record)(全状态,见 repository.CountRecordsByCourses 注释)
- type CourseRow struct {
- // ID 课程 ID。
- ID int64
- // InstitutionID 机构 ID(作用域列)。
- InstitutionID int64
- // Name 课程名称。
- Name string
- // ClassifyID 课程分类 ID(契约 JSON 名 `classify`)。
- ClassifyID int64
- // Status 课程状态(0 未发布 / 1 发布未开始 / 2 进行中 / 3 已结束)。
- Status int32
- // TeacherIDs 该课程排班规则涉及的老师(去重升序;无规则 → 空切片)。
- TeacherIDs []string
- // RecordCount 该课程的排班实例条数(全状态)。
- RecordCount int64
- }
- // CourseListQuery 端点 44 的查询入参(**Handler 只依赖本层类型**,不直接看到 repository 类型)。
- //
- // 字段:ClassifyID 分类过滤(<=0 不筛);Status 状态过滤(nil 不筛);Keyword 名称模糊;
- // Page/PageSize 分页(服务层钳制,上限 200)。
- type CourseListQuery struct {
- // ClassifyID 课程分类 ID(<=0 不筛)。
- ClassifyID int64
- // Status 课程状态(nil 不筛;0 未发布 / 1 发布未开始 / 2 进行中 / 3 已结束)。
- Status *int32
- // Keyword 课程名称模糊匹配(空不筛)。
- Keyword string
- // Page 页码(1 基;<1 → 1)。
- Page int
- // PageSize 每页条数(<1 → 10;>200 → 200)。
- PageSize int
- }
- // ListCourseRows 分页查询课程列表并补齐 `teacherIds` / `recordCount`(端点 44)。
- //
- // 入参:ctx;q 查询入参。
- // 出参:([]CourseRow, total, error);空结果为**非 nil 空切片**;5002。
- //
- // 两次批量查询而不是逐行查(一页最多 200 门课程,逐行 = 400 次往返)。
- func (s *CourseService) ListCourseRows(ctx context.Context, q CourseListQuery) ([]CourseRow, int64, error) {
- courses, total, err := s.courses.ListCourses(ctx, repository.CourseFilter{
- ClassifyID: q.ClassifyID, Status: q.Status, Keyword: q.Keyword,
- }, repository.PageQuery{Page: q.Page, PageSize: q.PageSize})
- if err != nil {
- return nil, 0, err
- }
- rows := make([]CourseRow, 0, len(courses))
- if len(courses) == 0 {
- return rows, total, nil
- }
- ids := make([]int64, 0, len(courses))
- for i := range courses {
- ids = append(ids, courses[i].ID)
- }
- teachers, err := s.courses.ListTeacherIDsByCourses(ctx, ids)
- if err != nil {
- return nil, 0, err
- }
- counts, err := s.courses.CountRecordsByCourses(ctx, ids)
- if err != nil {
- return nil, 0, err
- }
- for i := range courses {
- c := &courses[i]
- tids := teachers[c.ID]
- if tids == nil {
- tids = make([]string, 0)
- }
- rows = append(rows, CourseRow{
- ID: c.ID,
- InstitutionID: c.InstitutionID,
- Name: c.Name,
- ClassifyID: c.ClassifyID,
- Status: c.Status,
- TeacherIDs: tids,
- RecordCount: counts[c.ID],
- })
- }
- return rows, total, nil
- }
- // ---------------------------------------------------------------------------
- // 端点 46 / 47:排班规则(t_courses_class)
- // ---------------------------------------------------------------------------
- // ClassRow 端点 46 的行(契约 §3.4 C 行 46)。
- //
- // 字段来源:
- //
- // ClassID ← t_courses_class.class_id(**不是主键 id**:契约名是 `classId`,且旧库该列是外部班级 id)
- // CourseID ← t_courses_class.courses_id
- // TeacherID ← t_courses_class.teacher_id(NULL → 空串,契约 §1.5 用户 ID 是字符串)
- // Week ← t_courses_class.week(**原值字符串**)
- // Start ← t_courses_class.start_time(时分语义,1970-01-01 + 时分)
- // End ← t_courses_class.end_time(同上;可空)
- // CampusID ← t_courses_class.campus_id
- //
- // ⚠ **两处契约冲突(已登记,本实现按"列口径"落地,报告见交付说明)**:
- //
- // 1. 契约写 `week:number`「周日序(1~7)」。该列实际是**字符串**,取值 `"-1"`(全部周)/
- // `"0"`..`"6"` / 逗号多值(`"1,3,5"`)。数字 1~7 **表达不了**「全部周」与「多值」——
- // 按契约发 number 会让最常见的 `-1` 与任意多值规则**静默失真**(前端把"每天"显示成某一天)。
- // 因此下发**原值字符串**;若前端确需数字,须先在契约里定义 `-1`/多值如何编码。
- // 2. 契约写 `room:string`。**`t_courses_class` 没有 room 列**(只有 `address` / `remark`)。
- // 按 §3.4 ③「找不到列的一律不写」⇒ **不下发 `room`**,也**不用 `address` 顶替**
- // (地址 ≠ 教室,顶替会让前端把"上课地点"当"教室号"展示)。
- type ClassRow struct {
- // ClassID 班级 ID(`t_courses_class.class_id`)。
- ClassID int64
- // CourseID 课程 ID。
- CourseID int64
- // TeacherID 老师 ULID(NULL → 空串)。
- TeacherID string
- // Week 周规则原值("-1" 全部周 / "0" 周日 / "1".."6" / 逗号多值)。
- Week string
- // Start 上课时分(date 部分无业务含义,恒 1970-01-01)。
- Start *time.Time
- // End 下课时分(可空 = 未配置)。
- End *time.Time
- // CampusID 校区 ID(0 = 机构级)。
- CampusID int64
- }
- // ListClassRows 取某课程的全部排班规则(端点 46,**不分页**——见 Handler 的分页说明)。
- //
- // 入参:ctx;coursesID(<=0 → 2001)。
- // 出参:[]ClassRow(非 nil);3001(课程不存在/不在作用域)/5002。
- func (s *CourseService) ListClassRows(ctx context.Context, coursesID int64) ([]ClassRow, error) {
- classes, err := s.ListClasses(ctx, coursesID)
- if err != nil {
- return nil, err
- }
- rows := make([]ClassRow, 0, len(classes))
- for i := range classes {
- c := &classes[i]
- rows = append(rows, ClassRow{
- ClassID: c.ClassID,
- CourseID: c.CoursesID,
- TeacherID: strValue(c.TeacherID),
- Week: c.Week,
- Start: c.StartTime,
- End: c.EndTime,
- CampusID: c.CampusID,
- })
- }
- return rows, nil
- }
- // classSlot 排班时间槽(冲突判定的比较单位)。
- //
- // 字段:teacher 老师 ULID;campusID 校区;week 周规则;startMin/endMin 当日起止分钟数。
- type classSlot struct {
- teacher string
- campusID int64
- week weekRule
- startMin int
- endMin int
- }
- // AssertNoClassConflict 新建排班规则前的冲突判定(端点 47,冲突 → **3122**)。
- //
- // - 入参:ctx;in 与 `CreateClass` 同形状的入参(只读用:coursesID/teacherID/week/start/end)。
- // - 出参:nil 无冲突;apperr.ErrScheduleConflict(3122) 冲突;2002 时分格式非法;5002。
- //
- // 校区来源(**不取入参**):`CreateClassInput` 没有 `CampusID` 字段——新建规则的校区恒取
- // **作用域校区**(`repository.GetScopedID`)。因此冲突判定也必须以作用域校区为准,
- // 否则一条"给校区 10 排课"的请求会按"校区 0"去比、把真冲突漏掉。
- // 超管(作用域校区 = 0,可跨校区排课)**不做校区收敛**:保守地在全部校区里查同一老师的冲突
- // (宁可报一次 3122 让人确认,也不放过"同一老师同一时刻在两个校区"这种多半是误操作的情形)。
- func (s *CourseService) AssertNoClassConflict(ctx context.Context, in CreateClassInput) error {
- teacher := strings.TrimSpace(in.TeacherID)
- if teacher == "" {
- return nil
- }
- _, scopeCampusID := s.courses.GetScopedID()
- target, err := buildClassSlot(teacher, scopeCampusID, in.Week, in.StartTime, in.EndTime)
- if err != nil {
- return err
- }
- existing, err := s.courses.ListClassesByCourses(ctx, in.CoursesID, false)
- if err != nil {
- return err
- }
- for i := range existing {
- cur := &existing[i]
- if strValue(cur.TeacherID) != teacher {
- continue
- }
- slot, serr := buildClassSlot(teacher, cur.CampusID, cur.Week, hmString(cur.StartTime), hmString(cur.EndTime))
- if serr != nil {
- // 存量规则的 week/时分写坏:**不阻断新建**(否则一个脏行会让该课程永久无法排班),
- // 但也不能静默当作"无冲突"——用 5002 暴露给运维(脏数据必须被看见)。
- return serr
- }
- if slot.campusID != target.campusID && target.campusID != 0 {
- continue // 作用域校区 >0:只与本校区比;=0(超管):跨校区全查
- }
- if !weekRulesIntersect(slot.week, target.week) {
- continue
- }
- if !slotsOverlap(slot, target) {
- continue
- }
- return infraerrors.WithCause(apperr.ErrScheduleConflict,
- errString("service: 老师在该时段已有排班规则(classId="+strconv.FormatInt(cur.ClassID, 10)+")"))
- }
- return nil
- }
- // buildClassSlot 把一条排班规则(或其入参)转成可比较的时间槽。
- //
- // 入参:teacher;campusID;week 周规则;startRaw/endRaw 时分("HH:MM",start 必填)。
- // 出参:classSlot;2002(week 非法 / 时分格式非法)。
- func buildClassSlot(teacher string, campusID int64, week, startRaw, endRaw string) (classSlot, error) {
- rule, err := parseWeekRule(week)
- if err != nil {
- return classSlot{}, err
- }
- start, err := parseHHMM(startRaw, "上课时间")
- if err != nil {
- return classSlot{}, err
- }
- slot := classSlot{
- teacher: strings.TrimSpace(teacher),
- campusID: campusID,
- week: rule,
- startMin: start.Hour()*60 + start.Minute(),
- }
- if strings.TrimSpace(endRaw) != "" {
- end, eerr := parseHHMM(endRaw, "下课时间")
- if eerr != nil {
- return classSlot{}, eerr
- }
- slot.endMin = end.Hour()*60 + end.Minute()
- }
- return slot, nil
- }
- // weekRulesIntersect 判断两条周规则是否有交集(`-1` 表示全部周)。
- //
- // 入参:a/b weekRule。出参:true 有交集。
- func weekRulesIntersect(a, b weekRule) bool {
- if a.all || b.all {
- return true
- }
- for day := 0; day < 7; day++ {
- if a.days[day] && b.days[day] {
- return true
- }
- }
- return false
- }
- // slotsOverlap 判断两个时间槽是否重叠(`[start,end)` 半开区间;end 缺失按"点"处理)。
- //
- // 入参:a/b classSlot(调用方已保证同老师、同校区、周规则有交集)。
- // 出参:true 重叠。
- func slotsOverlap(a, b classSlot) bool {
- if a.endMin <= a.startMin && b.endMin <= b.startMin {
- return a.startMin == b.startMin // 双方都没有下课时间:只能按"同一时刻"判
- }
- aEnd := a.endMin
- if aEnd <= a.startMin {
- aEnd = a.startMin + 1 // 单侧缺失:按 1 分钟占位,避免"缺 end 就永不冲突"
- }
- bEnd := b.endMin
- if bEnd <= b.startMin {
- bEnd = b.startMin + 1
- }
- return a.startMin < bEnd && b.startMin < aEnd
- }
- // hmString 把时分列转 "HH:MM"(nil → 空串)。
- //
- // 入参:t 时分列(1970-01-01 + 时分)。出参:`"09:30"` 形态。
- func hmString(t *time.Time) string {
- if t == nil {
- return ""
- }
- return twoDigits(t.Hour()) + ":" + twoDigits(t.Minute())
- }
- // twoDigits 两位左补零(避免引入 fmt 开销与格式串分散)。
- func twoDigits(v int) string {
- if v < 10 {
- return "0" + strconv.Itoa(v)
- }
- return strconv.Itoa(v)
- }
- // ---------------------------------------------------------------------------
- // 端点 53:报课学生列表
- // ---------------------------------------------------------------------------
- // StudentRow 端点 53 的行(契约 §3.4 C 行 53)。
- //
- // 字段来源:
- //
- // UserID ← t_courses_student.student_id
- // RealName ← **不下发**(姓名在 identity 域,academic 不持有 t_user;契约 §3.4 C 写
- // `realName:string`,但跨域只读须经 port ⇒ 本批留空不下发,登记偏差)
- // Pay ← t_courses_student.receivable(应收)
- // Cost ← t_courses_student.net_receipts(实收)
- // GiveCost ← t_courses_student.give_cost(已用赠送额)
- // RemainderReceivable ← t_courses_student.remainder_receivable(剩余应收 = **欠费真值**)
- // Status ← t_courses_student.status(1 正常 / 2 结束,数字码)
- type StudentRow struct {
- // StudentCourseID 报课行 ID(`t_courses_student.id`;契约未列,内部对账用,不下发)。
- StudentCourseID int64
- // UserID 学生 ULID。
- UserID string
- // Pay 应收(`receivable`)。
- Pay money.Amount
- // Cost 实收(`net_receipts`)。
- Cost money.Amount
- // GiveCost 已用赠送额(`give_cost`)。
- GiveCost money.Amount
- // RemainderReceivable 剩余应收(`remainder_receivable`,欠费真值)。
- RemainderReceivable money.Amount
- // Status 报课状态(1 正常 / 2 结束)。
- Status int32
- }
- // StudentListQuery 端点 53 的查询入参。
- //
- // 字段:CoursesID 课程(必填 >0);Status 报课状态(nil 不筛);Page/PageSize 分页。
- type StudentListQuery struct {
- // CoursesID 课程 ID(必填 >0)。
- CoursesID int64
- // Status 报课状态(nil 不筛;1 正常 / 2 结束)。
- Status *int32
- // Page 页码(1 基)。
- Page int
- // PageSize 每页条数(上限 200)。
- PageSize int
- }
- // ListStudentRows 分页查询报课学生(端点 53)。
- //
- // 入参:ctx;q 查询入参。
- // 出参:([]StudentRow, total, error);3001(课程不存在/不在作用域)/5002。
- // 空结果返回 `[]`(不是 3001:课程存在但没人报名是正常空态)。
- func (s *CourseService) ListStudentRows(ctx context.Context, q StudentListQuery) ([]StudentRow, int64, error) {
- coursesID := q.CoursesID
- if coursesID <= 0 {
- return nil, 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课程 id 必须 > 0"))
- }
- if _, err := s.courses.GetCourseByID(ctx, coursesID); err != nil {
- return nil, 0, err // 3001
- }
- var st *int8
- if q.Status != nil {
- v := int8(*q.Status)
- st = &v
- }
- rows, total, err := s.courses.ListCoursesStudents(ctx, repository.CoursesStudentFilter{
- CoursesID: coursesID, Status: st,
- }, repository.PageQuery{Page: q.Page, PageSize: q.PageSize})
- if err != nil {
- return nil, 0, err
- }
- out := make([]StudentRow, 0, len(rows))
- for i := range rows {
- row := &rows[i]
- pay, err := parseAmount(row.Receivable)
- if err != nil {
- return nil, 0, err
- }
- cost, err := parseAmount(row.NetReceipts)
- if err != nil {
- return nil, 0, err
- }
- give, err := parseAmount(row.GiveCost)
- if err != nil {
- return nil, 0, err
- }
- remainder, err := parseAmount(row.RemainderReceivable)
- if err != nil {
- return nil, 0, err
- }
- out = append(out, StudentRow{
- StudentCourseID: row.ID,
- UserID: row.StudentID,
- Pay: pay,
- Cost: cost,
- GiveCost: give,
- RemainderReceivable: remainder, // ⚠ 欠费列:**只读本列**,不读 unpaid(§3.8-E4)
- Status: int32(row.Status),
- })
- }
- return out, total, nil
- }
- // ---------------------------------------------------------------------------
- // 端点 54:报名 / 补报名
- // ---------------------------------------------------------------------------
- // EnrollInput 报名入参(端点 54)。
- //
- // ⚠ **契约 §2.4 端点 54 只给了响应字段(`enrolled/courseId/userId`),没有入参形状** ⇒
- // 本入参是 AC-13c 自定义的**最小可对账集合**,已在交付报告登记为「契约缺口待补」。
- //
- // 金额口径(**只做快照,不动任何资金账户**):
- //
- // net_receipts = Pay(本次实收)
- // receivable = Pay + RemainderReceivable(应收恒等式:应收 = 实收 + 剩余应收)
- // remainder_receivable= RemainderReceivable(默认为 0 ⇒ 全额缴清)
- // remainder_cost = Pay(尚未消耗)
- // remainder_number = Number + GiveNumber - 0
- // lesson_cost = LessonCost(单节课费快照,扣课时依据)
- //
- // **为什么不写资金流水**:报课行是"应收/实收快照",真实收款走财务域端点 3(充值)/4(消费),
- // 本端点若凭空写流水会产生"没有来源的余额变动",破坏 AC-17 的余额恒等式。
- type EnrollInput struct {
- // CoursesID 课程 ID(路径 `:id`)。
- CoursesID int64
- // StudentID 学生 ULID(必填)。
- StudentID string
- // ClassID 班级 ID(0 = 不指定)。
- ClassID int64
- // CampusID 校区(0 = 用作用域校区;>0 且与作用域校区不一致 → 1004)。
- CampusID int64
- // Pay 本次实收(元字符串,必填 ≥0)。
- Pay string
- // RemainderReceivable 剩余应收(元字符串,可空 → 0)。
- RemainderReceivable string
- // LessonCost 单节课费(元字符串,必填;扣课时依据)。
- LessonCost string
- // Number 课次(>=0)。
- Number int32
- // GiveNumber 赠送课次(>=0,可空 → 0)。
- GiveNumber int32
- // DiscountCash 现金折扣(元字符串,可空 → 0;**允许负**,模型红线)。
- DiscountCash string
- // GiveCost 已用赠送额(元字符串,可空 → 0)。
- GiveCost string
- // RecommenderUserID 推荐人(ULID,可空;分账上行起点)。
- RecommenderUserID string
- // Remark 备注。
- Remark string
- // OperatorID 操作人(作用域用户;写 confirm_discount_user_id —— 见 Enroll 注释)。
- OperatorID string
- }
- // Enroll 报名 / 补报名(端点 54)。
- //
- // 入参:ctx;in 入参。
- // 出参:*model.CoursesStudent(含自增 ID);2001/2002(金额/课次非法)/
- // 3001(课程不存在或不在作用域)/1004(跨校区)/**3143**(该学生已在本课程在读)/
- // 5002(DB)。
- //
- // 三条与"资金"相关的强制约束:
- // 1. **不写流水、不动余额**(见 EnrollInput 注释);
- // 2. `confirm_discount_user_id` 写**操作人**(模型红线:该列为空 = 该报课行不可充值/不可核销,
- // §3.8-A17 的 `confirm_discount_userid > 0` 判据在目标态即"本列非空")——
- // 漏写会让这行**永远进不了欠费核销候选**,且没有任何报错;
- // 3. 重复报名(同课程 + 同学生 + status=1 已存在)**拒绝**(3140)而不是静默成功:
- // 静默成功会让前端以为建了两行,而实际只有一行(后续扣课时只扣一行,账对不上)。
- func (s *CourseService) Enroll(ctx context.Context, in EnrollInput) (*model.CoursesStudent, error) {
- studentID := strings.TrimSpace(in.StudentID)
- if in.CoursesID <= 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课程 id 必须 > 0"))
- }
- if studentID == "" {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 报名需要 studentId"))
- }
- if in.Number < 0 || in.GiveNumber < 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课次不能为负"))
- }
- pay, err := parseCourseMoney(in.Pay, "实收", false)
- if err != nil {
- return nil, err
- }
- if pay.IsZero() && strings.TrimSpace(in.Pay) == "" {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 报名需要 pay(本次实收)"))
- }
- remainder, err := parseCourseMoney(in.RemainderReceivable, "剩余应收", false)
- if err != nil {
- return nil, err
- }
- lessonCost, err := parseCourseMoney(in.LessonCost, "单节课费", false)
- if err != nil {
- return nil, err
- }
- if strings.TrimSpace(in.LessonCost) == "" {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 报名需要 lessonCost(单节课费)"))
- }
- discountCash, err := parseCourseMoney(in.DiscountCash, "现金折扣", true)
- if err != nil {
- return nil, err
- }
- giveCost, err := parseCourseMoney(in.GiveCost, "已用赠送额", false)
- if err != nil {
- return nil, err
- }
- institutionID, scopeCampusID := s.courses.GetScopedID()
- if institutionID <= 0 {
- return nil, infraerrors.WithCause(apperr.ErrBusinessParam, errString("service: 缺少机构作用域(institution_id)"))
- }
- campusID := in.CampusID
- if campusID <= 0 {
- campusID = scopeCampusID
- } else if scopeCampusID > 0 && campusID != scopeCampusID {
- // 跨校区报名:作用域校区角色只能给本校区报名(1004,不是 3001——这是**作用域越界**)
- return nil, infraerrors.WithCause(infraerrors.ErrUnscoped,
- errString("service: campusId 与当前作用域校区不一致"))
- }
- bizNo, berr := newBizNo()
- if berr != nil {
- return nil, berr
- }
- row := &model.CoursesStudent{
- BizNo: bizNo,
- CoursesID: in.CoursesID,
- ClassID: in.ClassID,
- InstitutionID: institutionID,
- CampusID: campusID,
- StudentID: studentID,
- Type: model.CoursesStudentTypeNormal,
- Status: model.CoursesStudentStatusNormal,
- Receivable: pay.Add(remainder).String(),
- NetReceipts: pay.String(),
- Unpaid: "0.00", // ⚠ 废弃列:**有意写 0 且永不重算**(§3.8-E4 目标态不维护)
- LessonCost: lessonCost.String(),
- Number: in.Number,
- FinishedNumber: 0,
- DiscountCash: discountCash.String(),
- GiveNumber: in.GiveNumber,
- GiveCost: giveCost.String(),
- RemainderNumber: in.Number + in.GiveNumber,
- RemainderCost: pay.String(),
- RemainderReceivable: remainder.String(),
- BadDebt: "0.00",
- Remark: normalizeOptString(&in.Remark),
- }
- if uid := strings.TrimSpace(in.OperatorID); uid != "" {
- row.ConfirmDiscountUserID = &uid
- }
- if rid := strings.TrimSpace(in.RecommenderUserID); rid != "" {
- row.RecommenderUserID = &rid
- now := s.now()
- row.RecommenderTime = &now
- }
- 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 // 3001:课程不存在 / 不在作用域
- }
- existing, cerr := s.courses.CountActiveEnrollments(txCtx, in.CoursesID, studentID)
- if cerr != nil {
- return cerr
- }
- if existing > 0 {
- return infraerrors.WithCause(apperr.ErrEnrollmentDuplicate,
- errString("service: 该学生已在本课程在读报课,请勿重复报名"))
- }
- return s.courses.CreateCoursesStudent(txCtx, tx, row)
- })
- if err != nil {
- return nil, err
- }
- return row, nil
- }
- // ---------------------------------------------------------------------------
- // 端点 55:欠费列表
- // ---------------------------------------------------------------------------
- // ArrearsRow 端点 55 的行(契约 §3.4 C 行 55)。
- //
- // 字段来源:
- //
- // StudentID ← t_courses_student.student_id
- // CourseID ← t_courses_student.courses_id
- // RemainderReceivable ← t_courses_student.remainder_receivable(**唯一欠费判据**)
- // Counteracted ← `net_receipts > 0`(该报课行发生过实收/核销)
- //
- // ⚠ `counteracted` 的语义澄清:契约只写 `counteracted:bool`,未定义判据;表里**没有**
- // "已核销"标记列,可追溯到列的最近语义是"该行发生过实收/核销"= `net_receipts > 0`。
- // 另一种读法(`remainder_receivable <= 0`)在本列表里**恒 false**(列表本身就是 >0 的行),
- // 恒 false 的布尔字段等于没下发,故不采用。已登记偏差待契约确认。
- type ArrearsRow struct {
- // StudentID 学生 ULID。
- StudentID string
- // CourseID 课程 ID。
- CourseID int64
- // RemainderReceivable 剩余应收(> 0)。
- RemainderReceivable money.Amount
- // Counteracted 该报课行是否发生过实收/核销(`net_receipts > 0`)。
- Counteracted bool
- }
- // ArrearsListQuery 端点 55 的查询入参。
- //
- // 字段:StudentID 学生过滤(空 = 全部欠费);Page/PageSize 分页。
- type ArrearsListQuery struct {
- // StudentID 学生 ULID(空 = 不按学生筛)。
- StudentID string
- // Page 页码(1 基)。
- Page int
- // PageSize 每页条数(上限 200)。
- PageSize int
- }
- // ListArrearsRows 分页查询欠费报课行(端点 55)。
- //
- // 入参:ctx;q 查询入参。
- // 出参:([]ArrearsRow, total, error);空 → `[]`;5002。
- //
- // **筛选条件恒为 `remainder_receivable > 0`**(设计 §3.8-E4 / 契约 §2.4 端点 55 注)。
- // 绝不能用 `unpaid > 0`:该列 2018-05-17 起停止更新,真实数据几乎恒 0 ⇒ 用它筛"看起来
- // 正常、实际把所有欠费漏掉"(这正是本端点最容易写错、且**没有任何报错**的一处)。
- func (s *CourseService) ListArrearsRows(ctx context.Context, q ArrearsListQuery) ([]ArrearsRow, int64, error) {
- rows, total, err := s.courses.ListCoursesStudents(ctx, repository.CoursesStudentFilter{
- StudentID: q.StudentID,
- OnlyArrears: true,
- }, repository.PageQuery{Page: q.Page, PageSize: q.PageSize})
- if err != nil {
- return nil, 0, err
- }
- out := make([]ArrearsRow, 0, len(rows))
- for i := range rows {
- row := &rows[i]
- remainder, err := parseAmount(row.RemainderReceivable)
- if err != nil {
- return nil, 0, err
- }
- netReceipts, err := parseAmount(row.NetReceipts)
- if err != nil {
- return nil, 0, err
- }
- out = append(out, ArrearsRow{
- StudentID: row.StudentID,
- CourseID: row.CoursesID,
- RemainderReceivable: remainder,
- Counteracted: netReceipts.GreaterThan(0),
- })
- }
- return out, total, nil
- }
- // ---------------------------------------------------------------------------
- // 端点 57:学生课表
- // ---------------------------------------------------------------------------
- // ScheduleRow 端点 57 的行(**字段名待移动端契约定稿**——契约 §3.4 C 行 57 原文
- // 「字段名由移动端契约定(本轮 PC mock 未实现)」⇒ 本结构是 AC-13c 的**最小列溯源集合**,
- // 每个字段都能追到列,已在交付报告登记为「待移动端契约确认」)。
- //
- // 字段来源:
- //
- // RecordID ← t_courses_record.id
- // CourseID ← t_courses_record.courses_id
- // CourseName ← t_courses.name(批量回查)
- // TeacherID ← t_courses_record.teacher_id(NULL → 空串)
- // CampusID ← t_courses_record.campus_id
- // StartTime ← t_courses_record.start_time
- // EndTime ← t_courses_record.end_time
- // Status ← t_courses_record.status(0 未开始 / 1 上课中 / 2 已结束)
- // Cost ← t_courses_record_student.cost(无打卡行 → 0.00)
- // GiveCost ← t_courses_record_student.give_cost(无打卡行 → 0.00)
- // Pay ← t_courses_record_student.pay(无打卡行 → false)
- // Checked ← 是否存在该学生的打卡行(有行即 true,不看 cost)
- type ScheduleRow struct {
- // RecordID 排班实例 ID。
- RecordID int64
- // CourseID 课程 ID。
- CourseID int64
- // CourseName 课程名称(回查 t_courses;查不到 → 空串)。
- CourseName string
- // TeacherID 老师 ULID(可空 → 空串)。
- TeacherID string
- // CampusID 校区 ID。
- CampusID int64
- // StartTime 上课时间。
- StartTime *time.Time
- // EndTime 下课时间。
- EndTime *time.Time
- // Status 上课状态(0/1/2)。
- Status int32
- // Cost 本次课费(该学生的打卡行)。
- Cost money.Amount
- // GiveCost 本次使用赠送额。
- GiveCost money.Amount
- // Pay 是否已支付。
- Pay bool
- // Checked 是否已有该学生的打卡行。
- Checked bool
- }
- // ScheduleQuery 端点 57 的查询入参。
- //
- // 字段:StudentID 学生(必填);From/To 上课时间窗(`from <= start_time < to`,右开;nil 不限);
- // Page/PageSize 分页。
- type ScheduleQuery struct {
- // StudentID 学生 ULID(必填)。
- StudentID string
- // From 窗口起点(含;nil 不限)。
- From *time.Time
- // To 窗口终点(**不含**;nil 不限)。
- To *time.Time
- // Page 页码(1 基)。
- Page int
- // PageSize 每页条数(上限 200)。
- PageSize int
- }
- // ListScheduleRows 查询某学生的课表(端点 57)。
- //
- // 入参:ctx;q 查询入参。
- // 出参:([]ScheduleRow, total, error);2001(缺 studentId)/5002。
- func (s *CourseService) ListScheduleRows(ctx context.Context, q ScheduleQuery) ([]ScheduleRow, int64, error) {
- sid := strings.TrimSpace(q.StudentID)
- if sid == "" {
- return nil, 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课表需要 studentId"))
- }
- records, total, err := s.courses.ListStudentSchedule(ctx, sid, q.From, q.To,
- repository.PageQuery{Page: q.Page, PageSize: q.PageSize})
- if err != nil {
- return nil, 0, err
- }
- out := make([]ScheduleRow, 0, len(records))
- if len(records) == 0 {
- return out, total, nil
- }
- recordIDs := make([]int64, 0, len(records))
- courseIDs := make([]int64, 0, len(records))
- for i := range records {
- recordIDs = append(recordIDs, records[i].ID)
- courseIDs = append(courseIDs, records[i].CoursesID)
- }
- courses, err := s.courses.ListCoursesByIDs(ctx, courseIDs)
- if err != nil {
- return nil, 0, err
- }
- names := make(map[int64]string, len(courses))
- for i := range courses {
- names[courses[i].ID] = courses[i].Name
- }
- checked, err := s.records.ListRecordStudentsOfStudent(ctx, sid, recordIDs)
- if err != nil {
- return nil, 0, err
- }
- for i := range records {
- rec := &records[i]
- row := ScheduleRow{
- RecordID: rec.ID,
- CourseID: rec.CoursesID,
- CourseName: names[rec.CoursesID],
- TeacherID: strValue(rec.TeacherID),
- CampusID: rec.CampusID,
- StartTime: rec.StartTime,
- EndTime: rec.EndTime,
- Status: rec.Status,
- }
- if att, ok := checked[rec.ID]; ok {
- cost, cerr := parseAmount(att.Cost)
- if cerr != nil {
- return nil, 0, cerr
- }
- give, cerr := parseAmount(att.GiveCost)
- if cerr != nil {
- return nil, 0, cerr
- }
- row.Checked = true
- row.Cost = cost
- row.GiveCost = give
- row.Pay = att.Pay == model.CoursesRecordStudentPayPaid
- }
- out = append(out, row)
- }
- return out, total, nil
- }
- // ---------------------------------------------------------------------------
- // 端点 51:结课响应装配(只读,不进结账事务)
- // ---------------------------------------------------------------------------
- // DivideGrantRow 单条分账入账明细(端点 51 的 `divide[]`)——**team-lead 裁决后的最终形状**
- // `[{userId, type, amount}]`。
- //
- // 字段来源(**逐字段列溯源**,契约 §3.4 ③):
- //
- // UserID ← t_user_divide_into_log.user_id(这笔上课的钱分给了谁)
- // Type ← t_user_divide_into_log.type(分成类型 1 一次付清 / 2 按课次)
- // Amount ← t_user_divide_into_log.money(本次入账金额)
- //
- // ⚠ **契约原 mock 的 `level` 与 `quota` 均不下发(团队裁决,2026-11-09)**:
- //
- // `level` —— `t_user_divide_into_log` **没有层级列**;`t_user_divide_into.layer` 是**当前**层级,
- // 不是"入账当时"的层级,拿当前值顶替历史值 = 「事后调整树结构 ⇒ 历史记录显示错」,
- // 属静默错,比字段缺失更难查。
- // `quota` —— 同上:节点 quota 事后可被 `update_quota` 改,日志里没留"当时额度";
- // 用当前值顶替即是伪造历史。
- //
- // 为什么**不是**把整个数组删掉:它承载"这笔钱分给了谁、各多少"这个核心信息;
- // `level`/`quota` 只是附加维度,缺了不影响主信息。因此改为**补 `type`(有列)**、删两个无列字段。
- // 若产品确需"当时层级/当时额度":需在日志表加列并回填 = 迁移变更,另立需求(本批不做)。
- //
- // ⚠ 反模式提醒(团队明确禁止):为了让前端 mock 好看而给 `level` 填常量 1。
- // "字段有值、永远等于 1、没人能发现它是假的"是本类缺陷里最难查的一种。
- type DivideGrantRow struct {
- // UserID 获得分成的用户(ULID,源列 `user_id`)。
- UserID string
- // Type 分成类型(源列 `type`:1 一次付清 / 2 按课次)。
- Type int32
- // Amount 本次入账金额(源列 `money`)。
- Amount money.Amount
- }
- // PayrollTrace 课酬溯源(端点 51 的 `payroll`)。
- //
- // 字段来源:
- //
- // DetailID ← t_course_payment_detail.id(命中的课酬规则;未配置方案 → 0)
- // Reward ← t_courses_record.teacher_reward(本课课酬合计,**落库值**,不是重新试算值)
- type PayrollTrace struct {
- // DetailID 命中的课酬明细规则 ID(0 = 未配置方案 / 无法定位)。
- DetailID int64
- // Reward 本课课酬合计(= Σ record_student.teacher_reward)。
- Reward money.Amount
- }
- // RecordFinishView 端点 51 的响应(契约 §3.4 C 行 51 的字段全集,去掉无列字段)。
- //
- // 字段来源:
- //
- // RecordID ← t_courses_record.id
- // CourseID ← t_courses_record.courses_id
- // Status ← t_courses_record.status(打完课恒 2)
- // Changed ← 由 `Repeated` 取反(幂等命中 ⇒ false,本次未写任何表)
- // TotalSpend ← Σ t_courses_record_student.cost == t_courses_record.income
- // Divide ← t_user_divide_into_log(按 student+order_no 回读)
- // Payroll ← 见 PayrollTrace
- // PerStudent ← t_courses_record_student(本次打卡行)
- type RecordFinishView struct {
- // RecordID 上课记录 ID。
- RecordID int64
- // CourseID 课程 ID。
- CourseID int64
- // Status 上课状态(2 = 已结束)。
- Status int32
- // Changed 本次是否真的写了库(false = 幂等命中,未扣课时/未分账)。
- Changed bool
- // TotalSpend 本次扣课时合计。
- TotalSpend money.Amount
- // Divide 本次触发的分账入账明细(空 → [])。
- Divide []DivideGrantRow
- // Payroll 课酬溯源。
- Payroll PayrollTrace
- // PerStudent 逐学生打卡明细。
- PerStudent []FinishedStudent
- }
- // RecordViewService 结课响应装配服务(**只读**;不参与结账事务、不写任何表)。
- //
- // 为什么单独一个服务:`RecordService.FinishRecord` 的返回值只带"结账需要的数据"
- // (金额合计、逐学生明细),而契约还要求 `divide[]` 与 `payroll.detailId` 两个**溯源**字段。
- // 把它们塞进 FinishRecord(AC-11)会让结账事务多做两次无关查询、并在事务内做展示拼装;
- // 因此改为"事务提交后再只读装配"——即使装配失败也不影响已落库的结账结果(装配失败只报 5002)。
- type RecordViewService struct {
- records *repository.RecordRepo
- courses *repository.CourseRepo
- divide *repository.DivideRepo
- payroll *PayrollService
- sc *appscope.Scope
- }
- // NewRecordViewService 构造结课响应装配服务。
- //
- // 入参:db(必填);sc 作用域;payroll 课酬试算服务(**可 nil**:nil 时 `payroll.detailId` 恒 0,
- // 仍下发落库课酬 `reward`,前端"课酬金额"不受影响)。
- // 出参:*RecordViewService。
- func NewRecordViewService(db *gorm.DB, sc *appscope.Scope, payroll *PayrollService) *RecordViewService {
- return &RecordViewService{
- records: repository.NewRecordRepo(db, sc),
- courses: repository.NewCourseRepo(db, sc),
- divide: repository.NewDivideRepo(db),
- payroll: payroll,
- sc: sc,
- }
- }
- // FinishView 把 `FinishRecordResult` 装配成契约要求的响应(端点 51)。
- //
- // 入参:ctx;res 结账结果(非 nil)。
- // 出参:*RecordFinishView;5002(回读失败)。**任一溯源信息取不到都不阻断**:
- // `divide[]` 为空时给 `[]`、`detailId` 取不到给 0——结账已经提交,展示缺项不该变成失败。
- func (s *RecordViewService) FinishView(ctx context.Context, res *FinishRecordResult) (*RecordFinishView, error) {
- if res == nil {
- return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: FinishView 需要结课结果"))
- }
- view := &RecordFinishView{
- RecordID: res.RecordID,
- Status: res.Status,
- Changed: !res.Repeated,
- TotalSpend: res.Income,
- Divide: make([]DivideGrantRow, 0, 2),
- Payroll: PayrollTrace{Reward: res.TeacherReward},
- PerStudent: res.Students,
- }
- rec, err := s.records.GetRecordByID(ctx, res.RecordID)
- if err != nil {
- return nil, err // 3001/5002:刚结完的课读不到 = 环境异常,按原码上抛
- }
- view.CourseID = rec.CoursesID
- // 分账明细:按 (学生, orderNo=recordId) 回读(与 DivideService.AddLog 的幂等键同源)
- for i := range res.Students {
- studentID := res.Students[i].StudentID
- logs, lerr := s.divide.ListLogsByStudentOrder(ctx, studentID, strconv.FormatInt(res.RecordID, 10))
- if lerr != nil {
- return nil, lerr
- }
- for j := range logs {
- amount, perr := parseAmount(logs[j].Money)
- if perr != nil {
- return nil, perr
- }
- view.Divide = append(view.Divide, DivideGrantRow{
- UserID: logs[j].UserID,
- Type: int32(logs[j].Type),
- Amount: amount,
- })
- }
- }
- view.Payroll.DetailID = s.lookupPayrollDetailID(ctx, rec, res.ActualEndTime)
- return view, nil
- }
- // lookupPayrollDetailID 回读本课命中的课酬规则 ID(只为**溯源展示**,不影响落库金额)。
- //
- // 入参:ctx;rec 上课记录;at 上课时间(方案 effect_date 判定)。
- // 出参:命中的 `t_course_payment_detail.id`;未配置方案 / 无老师 / 无分类 / 任何错误 → **0**。
- //
- // 三条"取不到就返回 0、不冒泡错误"的理由:
- // 1. 结账已提交,展示缺项不该让整个请求失败(`payroll.reward` 仍有值);
- // 2. 3112(无方案)在结账侧已被显式降级为 0 课酬,溯源侧同理;
- // 3. 这里的试算**不带教龄**(见 `recordRewardQuoter` 的偏差注释),只用于定位规则 id。
- func (s *RecordViewService) lookupPayrollDetailID(ctx context.Context, rec *model.CoursesRecord, at time.Time) int64 {
- if s.payroll == nil || rec == nil {
- return 0
- }
- teacherID := strValue(rec.TeacherID)
- if teacherID == "" {
- return 0
- }
- course, err := s.courses.GetCourseByID(ctx, rec.CoursesID)
- if err != nil || course.ClassifyID <= 0 {
- return 0
- }
- quote, qerr := s.payroll.QuoteTeacherReward(ctx, QuotePayrollInput{
- TeacherID: teacherID,
- TeacherExp: 0, // ⚠ AC-11 端口不携带教龄,统一按 0(偏差已登记)
- InstitutionID: rec.InstitutionID,
- CampusID: rec.CampusID,
- ClassifyID: course.ClassifyID,
- Date: at,
- })
- if qerr != nil || quote == nil {
- return 0
- }
- return quote.DetailID
- }
- // recordRewardQuoter 把 `PayrollService` 适配成 `RecordService` 的课酬端口(AC-11 `TeacherRewardQuoter`)。
- //
- // ---------------------------------------------------------------------------
- // 权威口径:**PHP 应用层**,不是 dump(`min_payment` 在存储过程里零命中)
- // ---------------------------------------------------------------------------
- //
- // 逐行照抄 `E://study//php//ysw-finance//yuansiwei-finance//modules//courses//helpers//CoursesRecordHelper.php:781-821`
- // (方法 `settlementExpenses`,正常结课与补签共用):
- //
- // $percentage = $coursePaymentDetail->percentage/100; // 百分数 → 比率("85.0" → 0.85)
- // $addedCost = $coursePaymentDetail->reward; // 额外课酬
- // $income = round(sum(cost), 3); // 本课全部学生 cost 合计
- // $teacherReward = 0;
- // if ($income>0 || $attendClassStudentNumber) { // 守卫:有学生即使收入 0 也算
- // $teacherReward = round($income*$percentage,3) + $addedCost; // ① 先算提成、② 再加 reward
- // if ($maxPayment>0 && $teacherReward>$maxPayment) $teacherReward = $maxPayment; // ③ 先夹上限
- // elseif ($minPayment>0 && $teacherReward<$minPayment) $teacherReward = $minPayment; // ④ 再夹下限
- // }
- // if ($teacherRewardType==2) { // 固定课酬:**不看比例**
- // $teacherReward = self::$makeupCost[$stuCount] ?? self::$makeupCost['max'];
- // }
- // if ($attendClassStudentNumber<=0) $teacherReward = 0; // 无人上课 → 课酬 0
- //
- // ⚠ **顺序是「加 reward → 再夹 [min,max]」,不是「夹完再加 reward」**:
- // 当"提成 + reward"超过 `max_payment` 时,结果是 **max(reward 被吃掉)**;
- // 反过来写会得到 `max + reward` = **多发钱**。这条顺序差有真实金额影响,改动前先回 PHP 原文。
- //
- // 另外三条照抄语义:
- // - `max`/`min` 为 **0 = 不设限**(不是"上限为 0");
- // - **`elseif` = 上限优先**:两者同时触发时取 `max`;
- // - `percentage` 是**百分数**:`"85.0"` 表示 85%,不是 0.85,也不是 8500。
- //
- // 实现红线:
- // - **全程整数分运算**,比例用"千分之一"整数承载(`"85.0"` → `850`),`+500 后整除 1000` = 四舍五入到分;
- // - `percentage` 列写坏(非数字/负数/小数位过多)→ **ErrDBError 整体回滚**,不静默按 0 算
- // ("课酬算成 0 却结课成功"是少发钱,比报错难查得多)。
- //
- // 已知口径缺口(**不是静默正确**):`TeacherExp` 目前恒 0(AC-11 未接教师档案的年数换算),
- // 因此**只会命中 `teacher_exp_min = 0` 的档位**;若某机构的规则全从 `teacher_exp_min > 0` 起,
- // 会走"无规则命中 ⇒ 3112 ⇒ 课酬 0.00"。补齐需接 `t_teacher_information.teacher_exp`(起算日 → 年数)。
- type recordRewardQuoter struct {
- svc *PayrollService
- }
- // QuoteRecordReward 实现 `service.TeacherRewardQuoter`。
- //
- // 入参:ctx;in 端口入参(含 `Income` 比例基数、`TeacherExp` 教龄)。
- // 出参:本课课酬(元);3112 原样上抛(由 RecordService 降级为 0 课酬);列写坏 → 5002 整体回滚。
- func (q recordRewardQuoter) QuoteRecordReward(ctx context.Context, in RewardQuoteInput) (money.Amount, error) {
- if q.svc == nil {
- return 0, nil
- }
- quote, err := q.svc.QuoteTeacherReward(ctx, QuotePayrollInput{
- TeacherID: in.TeacherID,
- TeacherExp: in.TeacherExp,
- InstitutionID: in.InstitutionID,
- CampusID: in.CampusID,
- ClassifyID: in.ClassifyID,
- Date: in.RecordDate,
- })
- if err != nil {
- return 0, err
- }
- if quote == nil {
- return 0, nil
- }
- return computeRecordReward(quote, in.Income, in.StudentCount, in.RewardType)
- }
- // computeRecordReward 由"命中的课酬规则 + 本次课收入/人数/结算方式"算出单节课课酬。
- //
- // 入参:quote 命中规则(Percentage/Min/Max/Reward);income 本次课收入;
- // studentCount 上课人数;rewardType 结算方式(1 正常 / 2 固定课酬)。
- // 出参:单节课课酬(分);2002(收入为负 = 调用方缺陷)/5002(percentage 列写坏)。
- //
- // 顺序**逐行对齐 PHP**(`CoursesRecordHelper.php:794-821`),见 `recordRewardQuoter` 注释:
- //
- // 守卫 → income×pct + reward → 夹上限 → 否则夹下限 → (type=2 覆盖为固定课酬)→ 无人则 0
- func computeRecordReward(quote *PayrollQuote, income money.Amount, studentCount int32, rewardType int8) (money.Amount, error) {
- if quote == nil {
- return 0, nil
- }
- if income.IsNegative() {
- return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("service: 课酬试算的收入不能为负"))
- }
- total := money.Amount(0)
- // 守卫:`if ($income>0 || $attendClassStudentNumber)` —— 收入 0 但有学生仍要算(0 + reward 再夹)
- if income.GreaterThan(0) || studentCount > 0 {
- base, perr := applyPercentage(quote.Percentage, income)
- if perr != nil {
- return 0, perr
- }
- // ① 先加 reward,② 再夹 —— 顺序不可交换(见类型注释)
- total = base.Add(quote.Reward)
- // ③ 上限优先(PHP 是 elseif:上限命中就不看下限,两者同时触发取 max)
- if quote.Max.GreaterThan(0) && total.GreaterThan(quote.Max) {
- total = quote.Max
- } else if quote.Min.GreaterThan(0) && total.LessThan(quote.Min) {
- total = quote.Min
- }
- }
- // 固定课酬(`teacher_reward_type == 2`):**不看比例**,按人数查 PHP 硬编码表
- if rewardType == model.CoursesRecordRewardTypeNoMinimum && studentCount > 0 {
- total = makeupCostFor(studentCount)
- }
- // 无人上课 ⇒ 不产生课酬(PHP `:821`;即使上面算出 reward 也要归零)
- if studentCount <= 0 {
- total = 0
- }
- if total.IsNegative() {
- return 0, infraerrors.WithCause(infraerrors.ErrDBError,
- errString("service: 课酬规则算出负值(percentage/min/max/reward 数据异常)"))
- }
- return total, nil
- }
- // makeupCostTable 固定课酬表(`teacher_reward_type == 2`):人数 → 单节课课酬(元)。
- //
- // **逐字复刻 PHP 魔法常量**(`CoursesRecordHelper.php:33`,注释原文"单独给学生补课上课的课酬,人数=>课酬"):
- //
- // static public $makeupCost = [1=>70,2=>100,3=>120,4=>150,5=>170,6=>200,'max'=>200];
- //
- // ⚠ **这张表在数据库里没有**(没有字典表、没有配置项)⇒ 只能按常量复刻;
- // 改表必须同时改 PHP 与 Go 两处,否则结课与双跑会分叉。D7 双跑也只能从 PHP 行为反推。
- var makeupCostTable = map[int32]money.Amount{
- 1: money.FromCents(7000),
- 2: money.FromCents(10000),
- 3: money.FromCents(12000),
- 4: money.FromCents(15000),
- 5: money.FromCents(17000),
- 6: money.FromCents(20000),
- }
- // makeupCostMax 超出表区间时的兜底值(PHP `'max' => 200`)。
- const makeupCostMaxCents = int64(20000)
- // makeupCostFor 按人数取固定课酬(人数 > 6 → 200.00,与 PHP `?? $makeupCost['max']` 一致)。
- //
- // 入参:studentCount(>0,由调用方保证)。出参:单节课课酬。
- func makeupCostFor(studentCount int32) money.Amount {
- if v, ok := makeupCostTable[studentCount]; ok {
- return v
- }
- return money.FromCents(makeupCostMaxCents)
- }
- // percentageDenom 比例的分母(`percentage` 是百分数:`"85.0"` = 85% ⇒ 分母 100;
- // 再乘 10 是为了容纳 1 位小数 ⇒ 统一用"千分之一"整数运算,见 `applyPercentage`)。
- const percentageDenom = 1000
- // applyPercentage 计算"收入 × 比例"的金额(整数分运算,四舍五入到分)。
- //
- // 入参:pct 百分比字符串(`decimal(5,1)`,如 `"85.0"`;空串按 0);income 本次课收入(分)。
- // 出参:金额(分);`ErrDBError`(列写坏:非数字 / 多于 2 位小数 / 负数)。
- //
- // 算法:`pctMilli = 百分比 × 10`(`"85.0"` → 850),`base = round(income × pctMilli / 1000)`。
- // 为什么不用 `money.Parse` 再乘 float:比例列是 `decimal(5,1)`,用 float 会在 `×0.85` 这类
- // 运算里带进二进制尾差(如 0.1+0.2 级),资金口径不允许。
- func applyPercentage(pct string, income money.Amount) (money.Amount, error) {
- text := strings.TrimSpace(pct)
- if text == "" {
- return 0, nil // 未配置比例:只算 min/max/reward 部分(dump 的 0 值语义)
- }
- if strings.HasPrefix(text, "-") {
- return 0, infraerrors.WithCause(infraerrors.ErrDBError,
- errString("service: 课酬比例不能为负 "+text))
- }
- intPart, fracPart, _ := strings.Cut(text, ".")
- if intPart == "" {
- return 0, infraerrors.WithCause(infraerrors.ErrDBError,
- errString("service: 课酬比例格式非法 "+text))
- }
- whole, perr := parseUintDigits(intPart)
- if perr != nil {
- return 0, infraerrors.WithCause(infraerrors.ErrDBError,
- errString("service: 课酬比例格式非法 "+text))
- }
- // 小数位:只取 1 位(列是 decimal(5,1));第 2 位 >=5 时进位
- tenth := int64(0)
- if fracPart != "" {
- if len(fracPart) > 2 {
- return 0, infraerrors.WithCause(infraerrors.ErrDBError,
- errString("service: 课酬比例小数位过多 "+text))
- }
- first, ferr := parseUintDigits(fracPart[:1])
- if ferr != nil {
- return 0, infraerrors.WithCause(infraerrors.ErrDBError,
- errString("service: 课酬比例格式非法 "+text))
- }
- tenth = first
- if len(fracPart) == 2 {
- second, serr := parseUintDigits(fracPart[1:])
- if serr != nil {
- return 0, infraerrors.WithCause(infraerrors.ErrDBError,
- errString("service: 课酬比例格式非法 "+text))
- }
- if second >= 5 {
- tenth++
- }
- }
- }
- pctMilli := whole*10 + tenth
- if pctMilli == 0 {
- return 0, nil
- }
- cents := income.Cents()
- // 溢出兜底:decimal(12,2) 上限 ≈ 1e10 分,pctMilli ≤ 1000 ⇒ 乘积 ≤ 1e13,int64 安全
- base := (cents*pctMilli + percentageDenom/2) / percentageDenom
- return money.FromCents(base), nil
- }
- // parseUintDigits 把纯数字串转 int64(比例解析用;非数字 → error)。
- func parseUintDigits(digits string) (int64, error) {
- if digits == "" {
- return 0, errString("empty")
- }
- var out int64
- for _, ch := range digits {
- if ch < '0' || ch > '9' {
- return 0, errString("not a digit")
- }
- out = out*10 + int64(ch-'0')
- if out > 1_000_000 { // 比例列 decimal(5,1) 不会超过这个量级
- return 0, errString("too large")
- }
- }
- return out, nil
- }
- // ---------------------------------------------------------------------------
- // 端点 48:课表实例列表
- // ---------------------------------------------------------------------------
- // RecordListQuery 端点 48 的查询入参(把 AC-11 的 `repository.RecordFilter` 包一层,
- // 使 Handler 不必 import repository —— 本包依赖方向:handler → service → repository)。
- //
- // 字段:CoursesID 课程(<=0 不筛);TeacherID 老师(空不筛);Status 状态(nil 不筛);
- // From/To 上课时间窗(`start_time` 闭区间,与 AC-11 `RecordFilter` 语义一致);Page/PageSize 分页。
- type RecordListQuery struct {
- // CoursesID 课程 ID(<=0 不筛)。
- CoursesID int64
- // TeacherID 老师 ULID(空不筛)。
- TeacherID string
- // Status 上课状态(nil 不筛;0 未开始 / 1 上课中 / 2 已结束)。
- Status *int32
- // From 上课时间下限(含;nil 不限)。
- From *time.Time
- // To 上课时间上限(含;nil 不限)。
- To *time.Time
- // Page 页码(1 基)。
- Page int
- // PageSize 每页条数(上限 200)。
- PageSize int
- }
- // ListRecordsByQuery 分页查询排班实例(端点 48)。
- //
- // 入参:ctx;q 查询入参。
- // 出参:([]model.CoursesRecord, total, error);5002。
- //
- // 时间窗口径**沿用 AC-11**(`start_time` 闭区间,两端都含):课表是"看某段时间有哪些课",
- // 与契约 §1.3 的 `from` 闭 / `to` 开**不同**——这里是有意的差异(契约 §1.3 约束的是
- // "区间筛选类查询"的通用规则,课表按"日期区间"展示,前端传 `to=2026-11-30` 期望含 30 号当天)。
- // 该差异已在交付报告登记。
- func (s *RecordService) ListRecordsByQuery(ctx context.Context, q RecordListQuery) ([]model.CoursesRecord, int64, error) {
- return s.List(ctx, repository.RecordFilter{
- CoursesID: q.CoursesID,
- TeacherID: q.TeacherID,
- Status: q.Status,
- From: q.From,
- To: q.To,
- }, repository.PageQuery{Page: q.Page, PageSize: q.PageSize})
- }
- // NewRecordRewardQuoter 构造课酬端口适配器(装配层用)。
- //
- // 入参:svc 课酬试算服务(可 nil → 适配器返回 0 课酬)。
- // 出参:TeacherRewardQuoter 实现。
- func NewRecordRewardQuoter(svc *PayrollService) TeacherRewardQuoter {
- return recordRewardQuoter{svc: svc}
- }
- // NewDivideServiceWithRecordRepo 构造分账服务并注入"首次扣费记录"端口(AC-14 装配用)。
- //
- // 入参:db;sc 请求作用域(仅用于构造 RecordRepo;分账域本身不做租户裁剪)。
- // 出参:*DivideService(`firstCharged` 已注入 = `repository.RecordRepo`,结构化满足端口)。
- //
- // 为什么放在 service 包而不是装配层 new 两个对象再拼:`repository.RecordRepo` 必须按
- // **请求作用域**构造(它继承 BaseRepo),把这个约束关在本包内,装配层就不必 import repository
- // (handler 层至今对 repository 零 import,这条依赖方向要保持)。
- func NewDivideServiceWithRecordRepo(db *gorm.DB, sc *appscope.Scope) *DivideService {
- return NewDivideService(db, repository.NewRecordRepo(db, sc))
- }
|