maintenance_service_test.go 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468
  1. // MaintenanceService 单测(AC-11):3 个修复型 SP(幂等可重跑)+ 2 个迁移型 SP(BUG#3 修正)。
  2. //
  3. // 测试栈:glebarez/sqlite 文件库(真实事务)。每条"反直觉口径"都配一条反向断言。
  4. package service
  5. import (
  6. "context"
  7. "errors"
  8. "testing"
  9. "time"
  10. "gorm.io/gorm"
  11. "yuansiwei/unified-backend/internal/academic/apperr"
  12. "yuansiwei/unified-backend/internal/academic/model"
  13. "yuansiwei/unified-backend/internal/academic/money"
  14. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  15. )
  16. // int64Ptr 返回 int64 指针(可空列用)。
  17. func int64Ptr(v int64) *int64 { return &v }
  18. // TestMaintenanceRequiresSuperAdmin 维护端点(61~63)仅超管可执行(1004)。
  19. func TestMaintenanceRequiresSuperAdmin(t *testing.T) {
  20. db := newLearningDB(t)
  21. svc := NewMaintenanceService(db, campusScope())
  22. ctx := context.Background()
  23. _, err := svc.BackfillCoursesMaxPeople(ctx)
  24. requireAppCode(t, err, infraerrors.ErrUnscoped.Code)
  25. _, err = svc.BackfillCoursesCampus(ctx)
  26. requireAppCode(t, err, infraerrors.ErrUnscoped.Code)
  27. _, err = svc.RebuildActivityUsedLog(ctx)
  28. requireAppCode(t, err, infraerrors.ErrUnscoped.Code)
  29. _, err = svc.ReplayActivitySigned(ctx)
  30. requireAppCode(t, err, infraerrors.ErrUnscoped.Code)
  31. // SP27 是业务动作(AC-08 链路),不做超管断言:普通校区角色可执行
  32. if _, err := svc.CounteractArrears(ctx, CounteractArrearsInput{
  33. StudentID: "01HSTUDENT0000000000009", InstitutionID: 1, CampusID: 10, Money: money.FromCents(100),
  34. }); err != nil {
  35. t.Fatalf("SP27 不应要求超管,实际: %v", err)
  36. }
  37. }
  38. // TestBackfillCoursesMaxPeopleIdempotent SP28:max_people=0 行按分类回填;重跑不再命中(幂等)。
  39. func TestBackfillCoursesMaxPeopleIdempotent(t *testing.T) {
  40. db := newLearningDB(t)
  41. svc := NewMaintenanceService(db, superScope())
  42. ctx := context.Background()
  43. classify := &model.CoursesClassify{InstitutionID: 1, CampusID: 10, Name: "大班", ClassType: 1000, MaxPeople: 30, MinPeople: 1, UnitPrice: "100.00"}
  44. if err := db.Create(classify).Error; err != nil {
  45. t.Fatalf("seed 分类失败: %v", err)
  46. }
  47. zeroClassify := &model.CoursesClassify{InstitutionID: 1, CampusID: 10, Name: "未配置", ClassType: 1000, MinPeople: 1, UnitPrice: "100.00"}
  48. if err := db.Create(zeroClassify).Error; err != nil {
  49. t.Fatalf("seed 分类(0) 失败: %v", err)
  50. }
  51. // max_people 的 DDL 默认值是 1000,GORM 会忽略零值 → 显式 UPDATE 成 0(模拟"分类未配置人数")
  52. if err := db.Model(&model.CoursesClassify{}).Where("id = ?", zeroClassify.ID).
  53. UpdateColumn("max_people", 0).Error; err != nil {
  54. t.Fatalf("把分类 max_people 置 0 失败: %v", err)
  55. }
  56. c1 := &model.Courses{Name: "待回填1", InstitutionID: 1, CampusID: 10, ClassifyID: classify.ID, MaxPeople: 0}
  57. c2 := &model.Courses{Name: "待回填2", InstitutionID: 1, CampusID: 10, ClassifyID: classify.ID, MaxPeople: 0}
  58. c3 := &model.Courses{Name: "分类也0", InstitutionID: 1, CampusID: 10, ClassifyID: zeroClassify.ID, MaxPeople: 0}
  59. done := &model.Courses{Name: "已配置", InstitutionID: 1, CampusID: 10, ClassifyID: classify.ID, MaxPeople: 15}
  60. for _, row := range []*model.Courses{c1, c2, c3, done} {
  61. if err := db.Create(row).Error; err != nil {
  62. t.Fatalf("seed 课程(%s) 失败: %v", row.Name, err)
  63. }
  64. }
  65. first, err := svc.BackfillCoursesMaxPeople(ctx)
  66. if err != nil {
  67. t.Fatalf("SP28 首次执行失败: %v", err)
  68. }
  69. if first.Scanned != 3 || first.Filled != 2 || first.Skipped != 1 {
  70. t.Fatalf("首次 scanned/filled/skipped=(%d,%d,%d),期望 (3,2,1)", first.Scanned, first.Filled, first.Skipped)
  71. }
  72. if got := loadCourseMaxPeople(t, db, c1.ID); got != 30 {
  73. t.Fatalf("c1.max_people=%d,期望 30(从分类继承)", got)
  74. }
  75. if got := loadCourseMaxPeople(t, db, done.ID); got != 15 {
  76. t.Fatalf("已配置课程的 max_people 被改动:%d,期望 15", got)
  77. }
  78. if got := loadCourseMaxPeople(t, db, c3.ID); got != 0 {
  79. t.Fatalf("分类 max_people=0 时不应写非 0:%d", got)
  80. }
  81. // 反向断言:重跑不新增改动(幂等可重跑)
  82. second, err := svc.BackfillCoursesMaxPeople(ctx)
  83. if err != nil {
  84. t.Fatalf("SP28 重跑失败: %v", err)
  85. }
  86. if second.Scanned != 1 || second.Filled != 0 {
  87. t.Fatalf("重跑 scanned/filled=(%d,%d),期望 (1,0)(仅剩分类也为 0 的那条)", second.Scanned, second.Filled)
  88. }
  89. }
  90. // loadCourseMaxPeople 读课程 max_people。
  91. func loadCourseMaxPeople(t *testing.T, db *gorm.DB, id int64) int32 {
  92. t.Helper()
  93. var row model.Courses
  94. if err := db.Where("id = ?", id).First(&row).Error; err != nil {
  95. t.Fatalf("读取课程(%d) 失败: %v", id, err)
  96. }
  97. return row.MaxPeople
  98. }
  99. // TestBackfillCoursesCampusSingleCampusOnly SP29:只有"唯一校区"的课程才回填;
  100. // 多校区 / 无排班规则 → **保持 0**(反向断言)。
  101. func TestBackfillCoursesCampusSingleCampusOnly(t *testing.T) {
  102. db := newLearningDB(t)
  103. svc := NewMaintenanceService(db, superScope())
  104. ctx := context.Background()
  105. single := &model.Courses{Name: "单校区", InstitutionID: 1, CampusID: 0}
  106. multi := &model.Courses{Name: "多校区", InstitutionID: 1, CampusID: 0}
  107. none := &model.Courses{Name: "无规则", InstitutionID: 1, CampusID: 0}
  108. for _, row := range []*model.Courses{single, multi, none} {
  109. if err := db.Create(row).Error; err != nil {
  110. t.Fatalf("seed 课程(%s) 失败: %v", row.Name, err)
  111. }
  112. }
  113. // single:只有校区 20 的规则(两条同一校区也算 1 个校区)
  114. seedClassRule(t, db, single.ID, 20, "-1", hm(9, 0), hm(10, 0), "100.00")
  115. seedClassRule(t, db, single.ID, 20, "-1", hm(14, 0), hm(15, 0), "100.00")
  116. // multi:校区 20 + 30
  117. seedClassRule(t, db, multi.ID, 20, "-1", hm(9, 0), hm(10, 0), "100.00")
  118. seedClassRule(t, db, multi.ID, 30, "-1", hm(9, 0), hm(10, 0), "100.00")
  119. // none:不建规则
  120. out, err := svc.BackfillCoursesCampus(ctx)
  121. if err != nil {
  122. t.Fatalf("SP29 执行失败: %v", err)
  123. }
  124. if out.Scanned != 3 || out.Updated != 1 || out.SkippedMultiCampus != 1 || out.SkippedNoRule != 1 {
  125. t.Fatalf("scanned/updated/multi/norule=(%d,%d,%d,%d),期望 (3,1,1,1)",
  126. out.Scanned, out.Updated, out.SkippedMultiCampus, out.SkippedNoRule)
  127. }
  128. if got := loadCourseCampus(t, db, single.ID); got != 20 {
  129. t.Fatalf("单校区课程 campus_id=%d,期望 20", got)
  130. }
  131. // 反向断言:多校区课程不回填(保持 0,否则会把机构级课程锁进某一个校区)
  132. if got := loadCourseCampus(t, db, multi.ID); got != 0 {
  133. t.Fatalf("多校区课程 campus_id=%d,期望 0(不回填)", got)
  134. }
  135. if got := loadCourseCampus(t, db, none.ID); got != 0 {
  136. t.Fatalf("无规则课程 campus_id=%d,期望 0", got)
  137. }
  138. // 重跑:只剩 multi/none 仍为 0 被扫描,Updated=0(幂等)
  139. second, err := svc.BackfillCoursesCampus(ctx)
  140. if err != nil {
  141. t.Fatalf("SP29 重跑失败: %v", err)
  142. }
  143. if second.Scanned != 2 || second.Updated != 0 {
  144. t.Fatalf("重跑 scanned/updated=(%d,%d),期望 (2,0)", second.Scanned, second.Updated)
  145. }
  146. }
  147. // loadCourseCampus 读课程 campus_id。
  148. func loadCourseCampus(t *testing.T, db *gorm.DB, id int64) int64 {
  149. t.Helper()
  150. var row model.Courses
  151. if err := db.Where("id = ?", id).First(&row).Error; err != nil {
  152. t.Fatalf("读取课程(%d) 失败: %v", id, err)
  153. }
  154. return row.CampusID
  155. }
  156. // TestCounteractArrearsOrderCapAndDedup SP27:按课程开课时分升序核销、逐行截到剩余额度、
  157. // 金额用完即停、余额留在账户;同 (courses,class) 多规则不重复核销(反向断言)。
  158. func TestCounteractArrearsOrderCapAndDedup(t *testing.T) {
  159. db := newLearningDB(t)
  160. svc := NewMaintenanceService(db, campusScope())
  161. ctx := context.Background()
  162. student := "01HSTUDENT0000000000101"
  163. confirm := "01HOPERATOR0000000000001"
  164. // 三门课:开课时分 08:00 / 10:00 / 12:00(决定核销顺序)
  165. early := &model.Courses{Name: "早课", InstitutionID: 1, CampusID: 10}
  166. mid := &model.Courses{Name: "中课", InstitutionID: 1, CampusID: 10}
  167. late := &model.Courses{Name: "晚课", InstitutionID: 1, CampusID: 10}
  168. for _, row := range []*model.Courses{early, mid, late} {
  169. if err := db.Create(row).Error; err != nil {
  170. t.Fatalf("seed 课程失败: %v", err)
  171. }
  172. }
  173. seedClassRule(t, db, early.ID, 10, "-1", hm(8, 0), hm(9, 0), "100.00")
  174. seedClassRule(t, db, mid.ID, 10, "-1", hm(10, 0), hm(11, 0), "100.00")
  175. seedClassRule(t, db, late.ID, 10, "-1", hm(12, 0), hm(13, 0), "100.00")
  176. // mid 课**两条**排班规则(同 courses+class)→ dump 的 1:N JOIN 会重复取同一报课行(去重断言)
  177. seedClassRule(t, db, mid.ID, 10, "-1", hm(18, 0), hm(19, 0), "100.00")
  178. rows := []*model.CoursesStudent{
  179. {BizNo: "CS-A", CoursesID: early.ID, ClassID: 1, InstitutionID: 1, CampusID: 10, StudentID: student,
  180. Status: model.CoursesStudentStatusNormal, RemainderReceivable: "100.00", NetReceipts: "0.00",
  181. LessonCost: "100.00", Number: 1, ConfirmDiscountUserID: &confirm},
  182. {BizNo: "CS-B", CoursesID: mid.ID, ClassID: 1, InstitutionID: 1, CampusID: 10, StudentID: student,
  183. Status: model.CoursesStudentStatusNormal, RemainderReceivable: "50.00", NetReceipts: "0.00",
  184. LessonCost: "100.00", Number: 1, ConfirmDiscountUserID: &confirm},
  185. {BizNo: "CS-C", CoursesID: late.ID, ClassID: 1, InstitutionID: 1, CampusID: 10, StudentID: student,
  186. Status: model.CoursesStudentStatusNormal, RemainderReceivable: "30.00", NetReceipts: "0.00",
  187. LessonCost: "100.00", Number: 1, ConfirmDiscountUserID: &confirm},
  188. }
  189. for _, row := range rows {
  190. if err := db.Create(row).Error; err != nil {
  191. t.Fatalf("seed 报课记录(%s) 失败: %v", row.BizNo, err)
  192. }
  193. }
  194. out, err := svc.CounteractArrears(ctx, CounteractArrearsInput{
  195. StudentID: student, InstitutionID: 1, CampusID: 10, Money: money.FromCents(12000), // 120.00
  196. })
  197. if err != nil {
  198. t.Fatalf("SP27 执行失败: %v", err)
  199. }
  200. if out.Applied.Cents() != 12000 || out.Remaining.Cents() != 0 {
  201. t.Fatalf("applied/remaining=(%d,%d),期望 (12000,0)", out.Applied.Cents(), out.Remaining.Cents())
  202. }
  203. if len(out.Items) != 2 {
  204. t.Fatalf("核销明细=%d 条,期望 2(100.00 + 20.00)", len(out.Items))
  205. }
  206. // 顺序:最早课先核销
  207. if out.Items[0].StudentCourseID != rows[0].ID || out.Items[0].Amount.Cents() != 10000 {
  208. t.Fatalf("首条核销=%+v,期望 CS-A 全额 100.00", out.Items[0])
  209. }
  210. if out.Items[1].StudentCourseID != rows[1].ID || out.Items[1].Amount.Cents() != 2000 {
  211. t.Fatalf("次条核销=%+v,期望 CS-B 截到 20.00", out.Items[1])
  212. }
  213. if got := cents(t, loadStudentCourse(t, db, rows[0].ID).NetReceipts); got != 10000 {
  214. t.Fatalf("CS-A net_receipts=%d 分,期望 10000", got)
  215. }
  216. if got := cents(t, loadStudentCourse(t, db, rows[0].ID).RemainderReceivable); got != 0 {
  217. t.Fatalf("CS-A remainder_receivable=%d 分,期望 0", got)
  218. }
  219. if got := cents(t, loadStudentCourse(t, db, rows[1].ID).RemainderReceivable); got != 3000 {
  220. t.Fatalf("CS-B remainder_receivable=%d 分,期望 3000(50-20)", got)
  221. }
  222. // 反向断言:第三条(晚课)没有被核销;CS-B 只因 1 条记录被核销(去重生效,未重复累加)
  223. if got := cents(t, loadStudentCourse(t, db, rows[2].ID).NetReceipts); got != 0 {
  224. t.Fatalf("CS-C 不应被核销,net_receipts=%d", got)
  225. }
  226. if got := cents(t, loadStudentCourse(t, db, rows[1].ID).NetReceipts); got != 2000 {
  227. t.Fatalf("CS-B net_receipts=%d 分,期望 2000(重复规则未导致重复核销)", got)
  228. }
  229. // money<=0 → no-op(dump: inputMoney<=0 直接 LEAVE)
  230. noop, err := svc.CounteractArrears(ctx, CounteractArrearsInput{
  231. StudentID: student, InstitutionID: 1, CampusID: 10, Money: 0,
  232. })
  233. if err != nil {
  234. t.Fatalf("money=0 应 no-op,实际: %v", err)
  235. }
  236. if noop.Applied.Cents() != 0 || len(noop.Items) != 0 {
  237. t.Fatalf("money=0 时不应核销,实际 %+v", noop)
  238. }
  239. // 参数校验:campus<=0 → 3140
  240. _, err = svc.CounteractArrears(ctx, CounteractArrearsInput{StudentID: student, InstitutionID: 1, CampusID: 0, Money: 100})
  241. requireAppCode(t, err, apperr.CodeBusinessParam)
  242. }
  243. // TestRebuildActivityUsedLogMapping SP30:DELETE + INSERT 重建;orderid = courses_studentid 映射不可改。
  244. func TestRebuildActivityUsedLogMapping(t *testing.T) {
  245. db := newLearningDB(t)
  246. svc := NewMaintenanceService(db, superScope())
  247. ctx := context.Background()
  248. pool := &model.CoursesActivityUserDiscount{
  249. ActivityID: 5, InstitutionID: 1, CampusID: 10, UserID: "01HUSER00000000000000001",
  250. Income: "500.00", DiscountAmount: "100.00", DiscountAmountUsed: "20.00",
  251. }
  252. if err := db.Create(pool).Error; err != nil {
  253. t.Fatalf("seed 优惠池失败: %v", err)
  254. }
  255. businessTime := time.Date(2026, 3, 1, 10, 30, 0, 0, time.Local)
  256. logs := []*model.CoursesActivityUserDiscountLog{
  257. {ActivityUserDiscountID: pool.ID, CoursesStudentID: int64Ptr(77), CoursesStudentNo: stringPtr("CS-UUID-1"),
  258. DiscountAmountUsed: "-20.00", CreateTime: businessTime, Memo: stringPtr("使用充值赠送金额")},
  259. // 孤儿流水(池行不存在)→ 新模型 activity_id/user_id NOT NULL,无法表达 → 跳过
  260. {ActivityUserDiscountID: 999, CoursesStudentNo: stringPtr("CS-UUID-2"), DiscountAmountUsed: "-5.00", CreateTime: businessTime},
  261. }
  262. for _, row := range logs {
  263. if err := db.Create(row).Error; err != nil {
  264. t.Fatalf("seed 优惠流水失败: %v", err)
  265. }
  266. }
  267. // 旧 used_log 脏行(应被清空)
  268. if err := db.Create(&model.CoursesActivityUsedLog{ActivityID: 9, UserID: "01HOLD000000000000000001", ActivityMoneyUsed: "1.00"}).Error; err != nil {
  269. t.Fatalf("seed 旧 used_log 失败: %v", err)
  270. }
  271. out, err := svc.RebuildActivityUsedLog(ctx)
  272. if err != nil {
  273. t.Fatalf("SP30 执行失败: %v", err)
  274. }
  275. if out.Scanned != 2 || out.Deleted != 1 || out.Inserted != 1 || out.Skipped != 1 {
  276. t.Fatalf("scanned/deleted/inserted/skipped=(%d,%d,%d,%d),期望 (2,1,1,1)",
  277. out.Scanned, out.Deleted, out.Inserted, out.Skipped)
  278. }
  279. var rebuilt []model.CoursesActivityUsedLog
  280. if err := db.Find(&rebuilt).Error; err != nil {
  281. t.Fatalf("读取重建结果失败: %v", err)
  282. }
  283. if len(rebuilt) != 1 {
  284. t.Fatalf("重建后 used_log=%d 行,期望 1(旧行已清空 + 孤儿跳过)", len(rebuilt))
  285. }
  286. row := rebuilt[0]
  287. if row.ActivityID != 5 || row.UserID != pool.UserID {
  288. t.Fatalf("重建行 activity/user=(%d,%s),期望 (5,%s)", row.ActivityID, row.UserID, pool.UserID)
  289. }
  290. // ★ 映射断言:order_no 取 courses_student_no(旧 UUID 单号),**不是** int64 id
  291. if row.OrderNo == nil || *row.OrderNo != "CS-UUID-1" {
  292. t.Fatalf("order_no=%v,期望 CS-UUID-1(orderid = courses_studentid 映射不可改)", row.OrderNo)
  293. }
  294. if got := cents(t, row.ActivityMoneyUsed); got != -2000 {
  295. t.Fatalf("activity_money_used=%d 分,期望 -2000(原值写入,不翻转符号)", got)
  296. }
  297. if !row.CreateTime.Equal(businessTime) {
  298. t.Fatalf("create_time=%v,期望 %v(业务时间逐值保留)", row.CreateTime, businessTime)
  299. }
  300. }
  301. // TestRebuildActivityUsedLogRollbackKeepsRows BUG#3 反向断言:中途失败必须**整体回滚**,
  302. // 旧 used_log 一行都不能少(dump 的 TRUNCATE 会隐式提交 → 数据全丢)。
  303. func TestRebuildActivityUsedLogRollbackKeepsRows(t *testing.T) {
  304. db := newLearningDB(t)
  305. pool := &model.CoursesActivityUserDiscount{
  306. ActivityID: 5, InstitutionID: 1, CampusID: 10, UserID: "01HUSER00000000000000002",
  307. Income: "100.00", DiscountAmount: "10.00", DiscountAmountUsed: "0.00",
  308. }
  309. if err := db.Create(pool).Error; err != nil {
  310. t.Fatalf("seed 优惠池失败: %v", err)
  311. }
  312. if err := db.Create(&model.CoursesActivityUserDiscountLog{
  313. ActivityUserDiscountID: pool.ID, CoursesStudentNo: stringPtr("CS-UUID-9"), DiscountAmountUsed: "-10.00",
  314. }).Error; err != nil {
  315. t.Fatalf("seed 优惠流水失败: %v", err)
  316. }
  317. old := &model.CoursesActivityUsedLog{ActivityID: 9, UserID: "01HOLD000000000000000002", ActivityMoneyUsed: "3.00"}
  318. if err := db.Create(old).Error; err != nil {
  319. t.Fatalf("seed 旧 used_log 失败: %v", err)
  320. }
  321. // 注入失败:所有 used_log 的 INSERT 一律报错(模拟"清空后重建失败")
  322. if err := db.Callback().Create().Before("gorm:create").Register("academic:fail_used_log_insert", func(tx *gorm.DB) {
  323. if tx.Statement != nil && tx.Statement.Schema != nil && tx.Statement.Schema.Table == "t_courses_activity_used_log" {
  324. _ = tx.AddError(errors.New("injected insert failure"))
  325. }
  326. }); err != nil {
  327. t.Fatalf("注册注入失败回调出错: %v", err)
  328. }
  329. svc := NewMaintenanceService(db, superScope())
  330. if _, err := svc.RebuildActivityUsedLog(context.Background()); err == nil {
  331. t.Fatal("注入失败后 SP30 应返回错误")
  332. }
  333. var rows []model.CoursesActivityUsedLog
  334. if err := db.Find(&rows).Error; err != nil {
  335. t.Fatalf("读取 used_log 失败: %v", err)
  336. }
  337. if len(rows) != 1 || rows[0].ActivityID != 9 {
  338. t.Fatalf("回滚后 used_log=%d 行(期望保留旧 1 行)—— DELETE+INSERT 必须同事务(BUG#3)", len(rows))
  339. }
  340. if got := cents(t, rows[0].ActivityMoneyUsed); got != 300 {
  341. t.Fatalf("回滚后旧行活动金额=%d 分,期望 300", got)
  342. }
  343. }
  344. // TestReplayActivitySigned SP31:只回写 status>0 的报名;已取消(status=0)不复活(反向断言)。
  345. func TestReplayActivitySigned(t *testing.T) {
  346. db := newLearningDB(t)
  347. svc := NewMaintenanceService(db, superScope())
  348. ctx := context.Background()
  349. responseUser := "01HRESPONSE000000000001"
  350. responseTime := time.Date(2026, 3, 2, 9, 0, 0, 0, time.Local)
  351. responseMsg := "审核通过"
  352. req := &model.UserBalanceRequest{
  353. BizNo: "BR-1", Type: model.BalanceRequestTypeRecharge, UserID: "01HUSER00000000000000003",
  354. Approved: model.BalanceRequestApprovedPassed, ResponseUserID: &responseUser,
  355. ResponseTime: &responseTime, ResponseMsg: &responseMsg, Money: "500.00",
  356. }
  357. if err := db.Create(req).Error; err != nil {
  358. t.Fatalf("seed 工单失败: %v", err)
  359. }
  360. pendingReq := &model.UserBalanceRequest{
  361. BizNo: "BR-2", Type: model.BalanceRequestTypeRecharge, UserID: "01HUSER00000000000000004",
  362. Approved: model.BalanceRequestApprovedPending, Money: "100.00",
  363. }
  364. if err := db.Create(pendingReq).Error; err != nil {
  365. t.Fatalf("seed 未审核工单失败: %v", err)
  366. }
  367. activePool := &model.CoursesActivityUserDiscount{
  368. ActivityID: 5, InstitutionID: 1, CampusID: 10, UserID: "01HUSER00000000000000003",
  369. BalanceRequestID: int64Ptr(req.ID), Income: "500.00", DiscountAmount: "100.00", DiscountAmountUsed: "20.00",
  370. }
  371. cancelledPool := &model.CoursesActivityUserDiscount{
  372. ActivityID: 6, InstitutionID: 1, CampusID: 10, UserID: "01HUSER00000000000000004",
  373. BalanceRequestID: int64Ptr(req.ID), Income: "500.00", DiscountAmount: "100.00", DiscountAmountUsed: "20.00",
  374. }
  375. pendingPool := &model.CoursesActivityUserDiscount{
  376. ActivityID: 7, InstitutionID: 1, CampusID: 10, UserID: "01HUSER00000000000000005",
  377. BalanceRequestID: int64Ptr(pendingReq.ID), Income: "100.00", DiscountAmount: "10.00",
  378. }
  379. for _, row := range []*model.CoursesActivityUserDiscount{activePool, cancelledPool, pendingPool} {
  380. if err := db.Create(row).Error; err != nil {
  381. t.Fatalf("seed 优惠池失败: %v", err)
  382. }
  383. }
  384. signedActive := &model.CoursesActivitySigned{ActivityID: 5, UserID: activePool.UserID, Status: model.ActivitySignedSigned}
  385. signedCancelled := &model.CoursesActivitySigned{ActivityID: 6, UserID: cancelledPool.UserID, Status: model.ActivitySignedCancelled}
  386. for _, row := range []*model.CoursesActivitySigned{signedActive, signedCancelled} {
  387. if err := db.Create(row).Error; err != nil {
  388. t.Fatalf("seed 报名失败: %v", err)
  389. }
  390. }
  391. out, err := svc.ReplayActivitySigned(ctx)
  392. if err != nil {
  393. t.Fatalf("SP31 执行失败: %v", err)
  394. }
  395. if out.Scanned != 2 {
  396. t.Fatalf("scanned=%d,期望 2(未审核工单的池行被 INNER JOIN 排除)", out.Scanned)
  397. }
  398. if out.Updated != 1 || out.SkippedCanceled != 1 {
  399. t.Fatalf("updated/skippedCanceled=(%d,%d),期望 (1,1)", out.Updated, out.SkippedCanceled)
  400. }
  401. var active model.CoursesActivitySigned
  402. if err := db.Where("id = ?", signedActive.ID).First(&active).Error; err != nil {
  403. t.Fatalf("读取报名失败: %v", err)
  404. }
  405. if cents(t, active.RechargeAmount) != 50000 {
  406. t.Fatalf("recharge_amount=%s,期望 500.00", active.RechargeAmount)
  407. }
  408. if cents(t, active.ActivityMoney) != 10000 {
  409. t.Fatalf("activity_money=%s,期望 100.00", active.ActivityMoney)
  410. }
  411. if cents(t, active.ActivityMoneyUsed) != 2000 {
  412. t.Fatalf("activity_money_used=%s,期望 20.00", active.ActivityMoneyUsed)
  413. }
  414. if active.RequestNo == nil || *active.RequestNo != "BR-1" {
  415. t.Fatalf("request_no=%v,期望 BR-1(工单 biz_no)", active.RequestNo)
  416. }
  417. if active.ResponseUserID == nil || *active.ResponseUserID != responseUser {
  418. t.Fatalf("response_user_id=%v,期望 %s", active.ResponseUserID, responseUser)
  419. }
  420. if active.ResponseTime == nil || !active.ResponseTime.Equal(responseTime) {
  421. t.Fatalf("response_time=%v,期望 %v", active.ResponseTime, responseTime)
  422. }
  423. // 反向断言:已取消报名(status=0)不被历史工单复活
  424. var cancelled model.CoursesActivitySigned
  425. if err := db.Where("id = ?", signedCancelled.ID).First(&cancelled).Error; err != nil {
  426. t.Fatalf("读取已取消报名失败: %v", err)
  427. }
  428. if cancelled.Status != model.ActivitySignedCancelled {
  429. t.Fatalf("已取消报名 status=%d,期望 0", cancelled.Status)
  430. }
  431. if cents(t, cancelled.RechargeAmount) != 0 || cancelled.RequestNo != nil {
  432. t.Fatalf("已取消报名被回写:recharge_amount=%s request_no=%v", cancelled.RechargeAmount, cancelled.RequestNo)
  433. }
  434. }