recommend_service_test.go 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453
  1. // RecommendService 单测(AC-08):老带新双边结算 + 取消充值活动 / 欠费核销的转调。
  2. //
  3. // 断言纪律(对照 §5.1 第 4/5 条):
  4. // 1. **双边各自入账**(不是"总额对了就行"):被推荐人与推荐人的
  5. // `balance` / `total_recommended_amount` **各加一次**、**各一条 107**。
  6. // 只断言"存在 107 流水"或"某一边余额增加"的用例,在"只给一边入账"的实现下依然全绿。
  7. // 2. **`balance_after` 各取本 user 全局快照**(两个值不要求相等,多校区/多历史下本来不等)。
  8. // 3. **幂等回放等价(纪律 5)**:造"首次结算后账户又被别的写入改过"的场景,
  9. // 再次结算必须 `repeated=true` 且响应体余额 == **当刻库值**(回放旧值即红)。
  10. // 4. **转调不重复实现**:取消充值活动与欠费核销分别只经 RefundService / MaintenanceService,
  11. // 用例同时证明其资金效果与幂等回读。
  12. package service
  13. import (
  14. "context"
  15. "testing"
  16. "time"
  17. "gorm.io/gorm"
  18. "yuansiwei/unified-backend/internal/academic/model"
  19. "yuansiwei/unified-backend/internal/academic/port"
  20. )
  21. // ---------------------------------------------------------------- 辅助
  22. // seedStudentCourse 落一行报课记录(老带新的机构/校区兜底来源)。
  23. func seedStudentCourse(t *testing.T, db *gorm.DB, id int64, studentID string,
  24. institutionID, campusID int64, remainderReceivable string) {
  25. t.Helper()
  26. confirmUser := "01HCONFIRM000000000000001"
  27. row := &model.CoursesStudent{
  28. ID: id, BizNo: "BIZ-" + time.Now().Format("150405.000000"), CoursesID: 1, ClassID: 1,
  29. InstitutionID: institutionID, CampusID: campusID, StudentID: studentID,
  30. RemainderReceivable: remainderReceivable, NetReceipts: "0.00",
  31. ConfirmDiscountUserID: &confirmUser,
  32. }
  33. if err := db.Create(row).Error; err != nil {
  34. t.Fatalf("seed 报课记录(%d) 失败: %v", id, err)
  35. }
  36. }
  37. // seedCoursesClass 落一行排班规则(欠费核销候选集的 INNER JOIN 对象)。
  38. func seedCoursesClass(t *testing.T, db *gorm.DB, coursesID, classID int64, startTime time.Time) {
  39. t.Helper()
  40. row := &model.CoursesClass{
  41. CoursesID: coursesID, ClassID: classID, InstitutionID: 1, CampusID: 10,
  42. StartTime: &startTime,
  43. }
  44. if err := db.Create(row).Error; err != nil {
  45. t.Fatalf("seed 排班规则失败: %v", err)
  46. }
  47. }
  48. // usedLogsOfActivity 取某活动的 used_log 行(按 id ASC)。
  49. func usedLogsOfActivity(t *testing.T, db *gorm.DB, activityID int64) []model.CoursesActivityUsedLog {
  50. t.Helper()
  51. var rows []model.CoursesActivityUsedLog
  52. if err := db.Where("activity_id = ?", activityID).Order("id ASC").Find(&rows).Error; err != nil {
  53. t.Fatalf("读 used_log 失败: %v", err)
  54. }
  55. return rows
  56. }
  57. // ---------------------------------------------------------------- 老带新结算(SP18)
  58. // TestSettleRecommendDoubleSided 核心用例:双边各入一次账、各一条 107、used_log 负值、signed 结算态。
  59. func TestSettleRecommendDoubleSided(t *testing.T) {
  60. db := newActivityDomainDB(t)
  61. svc := NewRecommendService(db, fundSuperScope())
  62. ctx := context.Background()
  63. newUser := fundUserID(40)
  64. recUser := fundUserID(41)
  65. seedActivityFull(t, db, 500, model.ActivityTypeRecommend, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
  66. rec := recUser
  67. if err := db.Create(&model.CoursesActivitySigned{
  68. ActivityID: 500, UserID: newUser, InstitutionID: 1, CampusID: 10,
  69. Status: model.ActivitySignedSigned, ActivityMoney: "100.00", ActivityMoneyUsed: "0.00",
  70. RecommendUserID: &rec,
  71. }).Error; err != nil {
  72. t.Fatalf("seed 老带新报名失败: %v", err)
  73. }
  74. res, err := svc.SettleRecommend(ctx, SettleRecommendInput{
  75. UserID: newUser, OrderNo: "7001", Remark: "报名成功",
  76. })
  77. if err != nil {
  78. t.Fatalf("老带新结算失败: %v", err)
  79. }
  80. if !res.Settled || res.Repeated {
  81. t.Fatalf("首次结算应 Settled=true/Repeated=false,实得 %+v", res)
  82. }
  83. if res.RecommendUserID != recUser || res.Amount.Cents() != 10000 {
  84. t.Fatalf("响应应回显推荐人与金额,实得 %+v", res)
  85. }
  86. // ① 双边 **各自** 加 balance + total_recommended_amount(不是"合计对就行")
  87. for _, uid := range []string{newUser, recUser} {
  88. global := readGlobal(t, db, uid)
  89. if cents(t, global.Balance) != 10000 {
  90. t.Fatalf("user %s 全局余额应 10000 分,实得 %d", uid, cents(t, global.Balance))
  91. }
  92. if cents(t, global.TotalRecommendedAmount) != 10000 {
  93. t.Fatalf("user %s 累计推荐额应 10000 分,实得 %d", uid, cents(t, global.TotalRecommendedAmount))
  94. }
  95. campus := readSchool(t, db, uid, 1, 10)
  96. if cents(t, campus.Balance) != 10000 {
  97. t.Fatalf("user %s 校区余额应 10000 分,实得 %d", uid, cents(t, campus.Balance))
  98. }
  99. if cents(t, campus.TotalRecommendedAmount) != 10000 {
  100. t.Fatalf("user %s 校区累计推荐额应 10000 分,实得 %d", uid, cents(t, campus.TotalRecommendedAmount))
  101. }
  102. }
  103. // ② **两条** 107,各属一个 user,金额都是 +m,logid = 报课单号
  104. for _, uid := range []string{newUser, recUser} {
  105. logs := logsByType(t, db, uid, model.ExpenseLogTypeRecommend)
  106. if len(logs) != 1 {
  107. t.Fatalf("user %s 应有 1 条 107,实得 %d 条", uid, len(logs))
  108. }
  109. if cents(t, logs[0].Money) != 10000 {
  110. t.Fatalf("user %s 的 107 金额应 10000 分,实得 %d", uid, cents(t, logs[0].Money))
  111. }
  112. if logs[0].LogID == nil || *logs[0].LogID != "7001" {
  113. t.Fatalf("user %s 的 107 logid 应为报课单号 7001,实得 %v", uid, logs[0].LogID)
  114. }
  115. if logs[0].BalanceAfter == nil || cents(t, *logs[0].BalanceAfter) != 10000 {
  116. t.Fatalf("user %s 的 balance_after 应为本 user 全局快照 10000 分,实得 %v", uid, logs[0].BalanceAfter)
  117. }
  118. }
  119. // ③ used_log:**负值** -m + 推荐人 + 单号
  120. used := usedLogsOfActivity(t, db, 500)
  121. if len(used) != 1 {
  122. t.Fatalf("used_log 应 1 条,实得 %d 条", len(used))
  123. }
  124. if cents(t, used[0].ActivityMoneyUsed) != -10000 {
  125. t.Fatalf("used_log 金额应为 -10000 分,实得 %d", cents(t, used[0].ActivityMoneyUsed))
  126. }
  127. if used[0].RecommendUserID == nil || *used[0].RecommendUserID != recUser {
  128. t.Fatalf("used_log 推荐人应为 %s,实得 %v", recUser, used[0].RecommendUserID)
  129. }
  130. // ④ signed:结算态(status=2、activity_money_used=+m)
  131. signed := readSignedRow(t, db, newUser, 500)
  132. if signed.Status != model.ActivitySignedAttended || cents(t, signed.ActivityMoneyUsed) != 10000 {
  133. t.Fatalf("报名行应结算为 status=2/used=10000,实得 status=%d used=%d",
  134. signed.Status, cents(t, signed.ActivityMoneyUsed))
  135. }
  136. }
  137. // TestSettleRecommendReplayReadsLiveState 幂等回放等价(纪律 5):
  138. // 首次结算后**别的写入改过账户**,再次结算的响应体必须是当刻库值(回放旧值即红)。
  139. func TestSettleRecommendReplayReadsLiveState(t *testing.T) {
  140. db := newActivityDomainDB(t)
  141. svc := NewRecommendService(db, fundSuperScope())
  142. ctx := context.Background()
  143. newUser := fundUserID(42)
  144. recUser := fundUserID(43)
  145. seedActivityFull(t, db, 501, model.ActivityTypeRecommend, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
  146. rec := recUser
  147. if err := db.Create(&model.CoursesActivitySigned{
  148. ActivityID: 501, UserID: newUser, InstitutionID: 1, CampusID: 10,
  149. Status: model.ActivitySignedSigned, ActivityMoney: "100.00", ActivityMoneyUsed: "0.00",
  150. RecommendUserID: &rec,
  151. }).Error; err != nil {
  152. t.Fatalf("seed 老带新报名失败: %v", err)
  153. }
  154. first, err := svc.SettleRecommend(ctx, SettleRecommendInput{UserID: newUser, OrderNo: "7002"})
  155. if err != nil {
  156. t.Fatalf("首次结算失败: %v", err)
  157. }
  158. // 造"首次执行后账户被其它写入改过":被推荐人又充了 55 元
  159. if err := db.Model(&model.UserExpense{}).Where("user_id = ?", newUser).
  160. UpdateColumn("balance", "155.00").Error; err != nil {
  161. t.Fatalf("模拟外部写入失败: %v", err)
  162. }
  163. if err := db.Model(&model.UserExpenseSchool{}).Where("user_id = ?", newUser).
  164. UpdateColumn("balance", "155.00").Error; err != nil {
  165. t.Fatalf("模拟外部写入失败: %v", err)
  166. }
  167. second, err := svc.SettleRecommend(ctx, SettleRecommendInput{UserID: newUser, OrderNo: "7002"})
  168. if err != nil {
  169. t.Fatalf("二次结算失败: %v", err)
  170. }
  171. if !second.Repeated {
  172. t.Fatalf("二次结算应 repeated=true(used_log 已存在)")
  173. }
  174. // ★ 回读断言:响应体每个金额字段 == 当刻库值
  175. if second.UserBalance.Cents() != 15500 {
  176. t.Fatalf("回放必须回读当刻余额 15500 分(旧值 10000 → 红),实得 %d", second.UserBalance.Cents())
  177. }
  178. if second.UserCampusBalance.Cents() != 15500 {
  179. t.Fatalf("回放必须回读当刻校区余额 15500 分,实得 %d", second.UserCampusBalance.Cents())
  180. }
  181. if second.UserLogID != first.UserLogID || second.UsedLogID != first.UsedLogID {
  182. t.Fatalf("回放应命中同一条 107/used_log(first=%d/%d second=%d/%d)",
  183. first.UserLogID, first.UsedLogID, second.UserLogID, second.UsedLogID)
  184. }
  185. // 幂等不得再写一笔钱
  186. if global := readGlobal(t, db, newUser); cents(t, global.Balance) != 15500 {
  187. t.Fatalf("幂等命中不得再次入账,实得余额 %d", cents(t, global.Balance))
  188. }
  189. if logs := logsByType(t, db, newUser, model.ExpenseLogTypeRecommend); len(logs) != 1 {
  190. t.Fatalf("幂等命中不得再写 107,实得 %d 条", len(logs))
  191. }
  192. }
  193. // TestSettleRecommendSkipsWithoutRecommender 无推荐人 → 零写入(dump 763340 LEAVE)。
  194. func TestSettleRecommendSkipsWithoutRecommender(t *testing.T) {
  195. db := newActivityDomainDB(t)
  196. svc := NewRecommendService(db, fundSuperScope())
  197. ctx := context.Background()
  198. user := fundUserID(44)
  199. seedActivityFull(t, db, 502, model.ActivityTypeRecommend, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
  200. if err := db.Create(&model.CoursesActivitySigned{
  201. ActivityID: 502, UserID: user, InstitutionID: 1, CampusID: 10,
  202. Status: model.ActivitySignedSigned, ActivityMoney: "100.00",
  203. }).Error; err != nil {
  204. t.Fatalf("seed 报名失败: %v", err)
  205. }
  206. res, err := svc.SettleRecommend(ctx, SettleRecommendInput{UserID: user, OrderNo: "7003"})
  207. if err != nil {
  208. t.Fatalf("结算失败: %v", err)
  209. }
  210. if res.Settled {
  211. t.Fatalf("无推荐人时应零写入(Settled=false),实得 %+v", res)
  212. }
  213. if len(usedLogsOfActivity(t, db, 502)) != 0 {
  214. t.Fatalf("无推荐人时不得写 used_log")
  215. }
  216. if len(logsByType(t, db, user, model.ExpenseLogTypeRecommend)) != 0 {
  217. t.Fatalf("无推荐人时不得写 107")
  218. }
  219. }
  220. // TestSettleRecommendResolvesScopeFromStudentCourse 活动 schoolid/campusid=0 → 从报课记录回查(dump 763348)。
  221. func TestSettleRecommendResolvesScopeFromStudentCourse(t *testing.T) {
  222. db := newActivityDomainDB(t)
  223. svc := NewRecommendService(db, fundSuperScope())
  224. ctx := context.Background()
  225. newUser := fundUserID(45)
  226. recUser := fundUserID(46)
  227. // 活动为"全机构池"(campus_id=0)且 institution_id=0 → 必须回查报课记录
  228. seedActivityFull(t, db, 503, model.ActivityTypeRecommend, "80.00", 0, 0, 0, model.ActivityStatusEnabled)
  229. rec := recUser
  230. if err := db.Create(&model.CoursesActivitySigned{
  231. ActivityID: 503, UserID: newUser, InstitutionID: 0, CampusID: 0,
  232. Status: model.ActivitySignedSigned, ActivityMoney: "80.00",
  233. RecommendUserID: &rec,
  234. }).Error; err != nil {
  235. t.Fatalf("seed 报名失败: %v", err)
  236. }
  237. seedStudentCourse(t, db, 7100, newUser, 1, 10, "0.00")
  238. res, err := svc.SettleRecommend(ctx, SettleRecommendInput{UserID: newUser, OrderNo: "7100"})
  239. if err != nil {
  240. t.Fatalf("结算失败: %v", err)
  241. }
  242. if !res.Settled || res.InstitutionID != 1 || res.CampusID != 10 {
  243. t.Fatalf("应从报课记录回查机构/校区 (1/10),实得 settled=%v %d/%d",
  244. res.Settled, res.InstitutionID, res.CampusID)
  245. }
  246. if got := cents(t, readGlobal(t, db, newUser).Balance); got != 8000 {
  247. t.Fatalf("被推荐人余额应 8000 分,实得 %d", got)
  248. }
  249. if got := cents(t, readSchool(t, db, recUser, 1, 10).Balance); got != 8000 {
  250. t.Fatalf("推荐人校区余额应 8000 分,实得 %d", got)
  251. }
  252. }
  253. // TestSettleRecommendSkipsWhenScopeUnresolvable 活动与报课记录都给不出机构/校区 → 零写入(dump 763355)。
  254. func TestSettleRecommendSkipsWhenScopeUnresolvable(t *testing.T) {
  255. db := newActivityDomainDB(t)
  256. svc := NewRecommendService(db, fundSuperScope())
  257. ctx := context.Background()
  258. user := fundUserID(47)
  259. seedActivityFull(t, db, 504, model.ActivityTypeRecommend, "80.00", 0, 0, 0, model.ActivityStatusEnabled)
  260. rec := fundUserID(48)
  261. if err := db.Create(&model.CoursesActivitySigned{
  262. ActivityID: 504, UserID: user, InstitutionID: 0, CampusID: 0,
  263. Status: model.ActivitySignedSigned, ActivityMoney: "80.00", RecommendUserID: &rec,
  264. }).Error; err != nil {
  265. t.Fatalf("seed 报名失败: %v", err)
  266. }
  267. res, err := svc.SettleRecommend(ctx, SettleRecommendInput{UserID: user, OrderNo: "999999"})
  268. if err != nil {
  269. t.Fatalf("结算失败: %v", err)
  270. }
  271. if res.Settled || len(usedLogsOfActivity(t, db, 504)) != 0 {
  272. t.Fatalf("机构/校区无法解析时应零写入,实得 %+v", res)
  273. }
  274. }
  275. // TestSettleRecommendSkipsOtherActivityTypes 非 type=4 的报名不进入老带新结算。
  276. func TestSettleRecommendSkipsOtherActivityTypes(t *testing.T) {
  277. db := newActivityDomainDB(t)
  278. svc := NewRecommendService(db, fundSuperScope())
  279. ctx := context.Background()
  280. user := fundUserID(49)
  281. seedActivityFull(t, db, 505, model.ActivityTypeRecharge, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
  282. rec := fundUserID(50)
  283. if err := db.Create(&model.CoursesActivitySigned{
  284. ActivityID: 505, UserID: user, InstitutionID: 1, CampusID: 10,
  285. Status: model.ActivitySignedSigned, ActivityMoney: "100.00", RecommendUserID: &rec,
  286. }).Error; err != nil {
  287. t.Fatalf("seed 报名失败: %v", err)
  288. }
  289. res, err := svc.SettleRecommend(ctx, SettleRecommendInput{UserID: user, OrderNo: "7004"})
  290. if err != nil {
  291. t.Fatalf("结算失败: %v", err)
  292. }
  293. if res.Settled {
  294. t.Fatalf("非老带新活动不得结算,实得 %+v", res)
  295. }
  296. }
  297. // ---------------------------------------------------------------- 取消充值活动(SP8 转调)
  298. // TestRecommendCancelRechargeActivityDelegates 转调 RefundService:209 流水 -m、signed 归 0、余额减少。
  299. func TestRecommendCancelRechargeActivityDelegates(t *testing.T) {
  300. db := newActivityDomainDB(t)
  301. fund := NewFundService(db, fundSuperScope(), nil)
  302. svc := NewRecommendService(db, fundSuperScope()).WithCampusReader(port.NewOrgCampusReader(db))
  303. ctx := context.Background()
  304. user := fundUserID(51)
  305. seedCampus(t, db, 10, 1, true)
  306. seedActivityFull(t, db, 506, model.ActivityTypeRecharge, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
  307. seedSignedFull(t, db, user, 506, model.ActivitySignedSigned, "100.00", "0.00")
  308. if _, err := fund.Recharge(ctx, RechargeInput{
  309. UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "300.00"), RequestID: "REQ-SEED-1",
  310. }); err != nil {
  311. t.Fatalf("准备余额失败: %v", err)
  312. }
  313. res, err := svc.CancelRechargeActivity(ctx, CancelRechargeActivityInput{
  314. UserID: user, ActivityID: 506, CampusID: 10,
  315. Money: mustAmount(t, "100.00"), RequestID: "REQ-CANCEL-1",
  316. })
  317. if err != nil {
  318. t.Fatalf("取消充值活动失败: %v", err)
  319. }
  320. if res.Repeated || res.InstitutionID != 1 || res.Balance.Cents() != 20000 {
  321. t.Fatalf("首次取消应 non-repeated/机构 1/余额 20000,实得 %+v", res)
  322. }
  323. if logs := logsByType(t, db, user, model.ExpenseLogTypeCancelRechargeAct); len(logs) != 1 ||
  324. cents(t, logs[0].Money) != -10000 {
  325. t.Fatalf("应写 1 条 209 且金额 -10000 分,实得 %+v", logs)
  326. }
  327. if signed := readSignedRow(t, db, user, 506); signed.Status != model.ActivitySignedCancelled {
  328. t.Fatalf("报名状态应归 0,实得 %d", signed.Status)
  329. }
  330. }
  331. // TestRecommendCancelRechargeReplayReadsLiveState 转调路径的幂等回放也必须回读当刻库值(纪律 5)。
  332. func TestRecommendCancelRechargeReplayReadsLiveState(t *testing.T) {
  333. db := newActivityDomainDB(t)
  334. fund := NewFundService(db, fundSuperScope(), nil)
  335. svc := NewRecommendService(db, fundSuperScope()).WithCampusReader(port.NewOrgCampusReader(db))
  336. ctx := context.Background()
  337. user := fundUserID(52)
  338. seedCampus(t, db, 10, 1, true)
  339. seedActivityFull(t, db, 507, model.ActivityTypeRecharge, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
  340. seedSignedFull(t, db, user, 507, model.ActivitySignedSigned, "100.00", "0.00")
  341. if _, err := fund.Recharge(ctx, RechargeInput{
  342. UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "300.00"), RequestID: "REQ-SEED-2",
  343. }); err != nil {
  344. t.Fatalf("准备余额失败: %v", err)
  345. }
  346. first, err := svc.CancelRechargeActivity(ctx, CancelRechargeActivityInput{
  347. UserID: user, ActivityID: 507, CampusID: 10,
  348. Money: mustAmount(t, "100.00"), RequestID: "REQ-CANCEL-2",
  349. })
  350. if err != nil {
  351. t.Fatalf("首次取消失败: %v", err)
  352. }
  353. if first.Balance.Cents() != 20000 {
  354. t.Fatalf("首次取消后校区余额应 20000 分,实得 %d", first.Balance.Cents())
  355. }
  356. // 首次执行后被别的写入改过(再充 50)
  357. if _, err := fund.Recharge(ctx, RechargeInput{
  358. UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "50.00"), RequestID: "REQ-SEED-3",
  359. }); err != nil {
  360. t.Fatalf("模拟外部写入失败: %v", err)
  361. }
  362. second, err := svc.CancelRechargeActivity(ctx, CancelRechargeActivityInput{
  363. UserID: user, ActivityID: 507, CampusID: 10,
  364. Money: mustAmount(t, "100.00"), RequestID: "REQ-CANCEL-2",
  365. })
  366. if err != nil {
  367. t.Fatalf("二次取消失败: %v", err)
  368. }
  369. if !second.Repeated {
  370. t.Fatalf("二次取消应 repeated=true")
  371. }
  372. if second.Balance.Cents() != 25000 {
  373. t.Fatalf("回放必须回读当刻校区余额 25000 分(旧值 20000 → 红),实得 %d", second.Balance.Cents())
  374. }
  375. if second.LogID != first.LogID {
  376. t.Fatalf("回放应命中同一条 209(first=%d second=%d)", first.LogID, second.LogID)
  377. }
  378. }
  379. // ---------------------------------------------------------------- 欠费核销(SP27 转调)
  380. // TestRecommendCounteractArrearsDelegates 转调 MaintenanceService:按剩余应收核销并写回两列。
  381. func TestRecommendCounteractArrearsDelegates(t *testing.T) {
  382. db := newActivityDomainDB(t)
  383. svc := NewRecommendService(db, fundSuperScope())
  384. ctx := context.Background()
  385. student := fundUserID(53)
  386. startAt := time.Date(2026, 11, 1, 9, 0, 0, 0, time.UTC)
  387. seedCoursesClass(t, db, 1, 1, startAt)
  388. seedStudentCourse(t, db, 7200, student, 1, 10, "60.00")
  389. res, err := svc.CounteractArrears(ctx, CounteractArrearsInput{
  390. StudentID: student, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "100.00"),
  391. })
  392. if err != nil {
  393. t.Fatalf("欠费核销失败: %v", err)
  394. }
  395. if res.Applied.Cents() != 6000 {
  396. t.Fatalf("应核销 6000 分(截到剩余应收),实得 %d", res.Applied.Cents())
  397. }
  398. if res.Remaining.Cents() != 4000 {
  399. t.Fatalf("未用完部分应 4000 分留在账户,实得 %d", res.Remaining.Cents())
  400. }
  401. var row model.CoursesStudent
  402. if err := db.Where("id = ?", 7200).Take(&row).Error; err != nil {
  403. t.Fatalf("读报课记录失败: %v", err)
  404. }
  405. if cents(t, row.NetReceipts) != 6000 || cents(t, row.RemainderReceivable) != 0 {
  406. t.Fatalf("核销后 net_receipts 应 6000/remainder 0,实得 %d/%d",
  407. cents(t, row.NetReceipts), cents(t, row.RemainderReceivable))
  408. }
  409. }