activity_discount_service_test.go 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545
  1. // ActivityDiscountService 单测(AC-07):充值赠送使用/退回 + 代金券使用/退回 + 可用额。
  2. //
  3. // 断言纪律(对照 §5.1 第 4 条「横切优先」——**不是**数 case 数量):
  4. // 1. **退回分配顺序(降序)是阻断级**:两次用券 100/50、退 120 ⇒ **先 50 再 70**。
  5. // 若把 `id DESC` 写成 `ASC`,总额同样是 120,只有"逐条明细"能证明分配错误 ⇒
  6. // 本文件断言 `Items` 的顺序与逐条金额,而不只断言合计。
  7. // 2. **盖帽**:退 40 只能退 40(单行冲回额 = min(|行金额|, 剩余应退))。
  8. // 3. **账务恒等式(自证失败式)**:`signed.activity_money_used == -Σ(used_log)`、
  9. // `pool.discount_amount_used == -Σ(discount_log)`——任何"少写一条流水/金额算错"都会立刻破式。
  10. // 4. **代金券一次全额退**:type=3 的退回额 = 券面额,再按最近一条消费额盖帽(A13 修正)。
  11. package service
  12. import (
  13. "context"
  14. "testing"
  15. "gorm.io/gorm"
  16. "yuansiwei/unified-backend/internal/academic/apperr"
  17. "yuansiwei/unified-backend/internal/academic/model"
  18. "yuansiwei/unified-backend/internal/academic/money"
  19. )
  20. // ---------------------------------------------------------------- 辅助
  21. // seedSignedFull 落一行报名行(可指定状态与两列金额)。
  22. func seedSignedFull(t *testing.T, db *gorm.DB, userID string, activityID int64, status int32,
  23. activityMoney, activityMoneyUsed string) {
  24. t.Helper()
  25. row := &model.CoursesActivitySigned{
  26. ActivityID: activityID, UserID: userID, InstitutionID: 1, CampusID: 10,
  27. Status: status, ActivityMoney: activityMoney, ActivityMoneyUsed: activityMoneyUsed,
  28. }
  29. if err := db.Create(row).Error; err != nil {
  30. t.Fatalf("seed 报名行失败: %v", err)
  31. }
  32. }
  33. // seedPool 落一行赠送池(返回池 ID)。
  34. func seedPool(t *testing.T, db *gorm.DB, userID string, activityID int64,
  35. discountAmount, discountAmountUsed string) int64 {
  36. t.Helper()
  37. row := &model.CoursesActivityUserDiscount{
  38. ActivityID: activityID, InstitutionID: 1, CampusID: 10, UserID: userID,
  39. DiscountAmount: discountAmount, DiscountAmountUsed: discountAmountUsed,
  40. }
  41. if err := db.Create(row).Error; err != nil {
  42. t.Fatalf("seed 赠送池失败: %v", err)
  43. }
  44. return row.ID
  45. }
  46. // readSignedUsed 读报名行已用额(分)。
  47. func readSignedUsed(t *testing.T, db *gorm.DB, userID string, activityID int64) int64 {
  48. t.Helper()
  49. row := readSignedRow(t, db, userID, activityID)
  50. if row == nil {
  51. t.Fatalf("报名行不存在(user=%s,activity=%d)", userID, activityID)
  52. }
  53. return cents(t, row.ActivityMoneyUsed)
  54. }
  55. // sumUsedLogAmount 汇总某活动某用户的 used_log 金额(分;消费为负、退回为正)。
  56. func sumUsedLogAmount(t *testing.T, db *gorm.DB, activityID int64, userID string) int64 {
  57. t.Helper()
  58. var rows []model.CoursesActivityUsedLog
  59. if err := db.Where("activity_id = ? AND user_id = ?", activityID, userID).Find(&rows).Error; err != nil {
  60. t.Fatalf("读 used_log 失败: %v", err)
  61. }
  62. total := int64(0)
  63. for i := range rows {
  64. total += cents(t, rows[i].ActivityMoneyUsed)
  65. }
  66. return total
  67. }
  68. // readPoolUsed 读池已用额(分)。
  69. func readPoolUsed(t *testing.T, db *gorm.DB, poolID int64) int64 {
  70. t.Helper()
  71. var row model.CoursesActivityUserDiscount
  72. if err := db.Where("id = ?", poolID).Take(&row).Error; err != nil {
  73. t.Fatalf("读赠送池(%d) 失败: %v", poolID, err)
  74. }
  75. return cents(t, row.DiscountAmountUsed)
  76. }
  77. // sumDiscountLogAmount 汇总某池的 discount_log 金额(分;消费为负、退回为正)。
  78. func sumDiscountLogAmount(t *testing.T, db *gorm.DB, poolID int64) int64 {
  79. t.Helper()
  80. var rows []model.CoursesActivityUserDiscountLog
  81. if err := db.Where("activity_user_discount_id = ?", poolID).Find(&rows).Error; err != nil {
  82. t.Fatalf("读 discount_log 失败: %v", err)
  83. }
  84. total := int64(0)
  85. for i := range rows {
  86. total += cents(t, rows[i].DiscountAmountUsed)
  87. }
  88. return total
  89. }
  90. // ---------------------------------------------------------------- 充值赠送:使用(SP12)
  91. // TestUseRechargeGiftCapsToRemainingAndKeepsLedgerIdentity
  92. // 使用额不得超过活动剩余额(A9 修正),且 `signed.used == -Σ used_log` 恒成立。
  93. func TestUseRechargeGiftCapsToRemainingAndKeepsLedgerIdentity(t *testing.T) {
  94. db := newActivityDomainDB(t)
  95. svc := NewActivityDiscountService(db, fundSuperScope())
  96. ctx := context.Background()
  97. user := fundUserID(20)
  98. seedActivityFull(t, db, 400, model.ActivityTypeRecharge, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
  99. seedSignedFull(t, db, user, 400, model.ActivitySignedAttended, "100.00", "0.00")
  100. // 第一次:用 30(小于剩余 100)
  101. first, err := svc.UseRechargeGift(ctx, UseRechargeGiftInput{
  102. UserID: user, ActivityID: 400, StudentCourseID: 5001, Amount: mustAmount(t, "30.00"),
  103. })
  104. if err != nil {
  105. t.Fatalf("使用充值赠送失败: %v", err)
  106. }
  107. if first.UsedAmount.Cents() != 3000 || first.Remaining.Cents() != 7000 {
  108. t.Fatalf("首用应 3000 分、剩余 7000 分,实得 %d/%d",
  109. first.UsedAmount.Cents(), first.Remaining.Cents())
  110. }
  111. // 第二次:请求 90,但只剩 70 → 只能扣 70(A9:逐行递减到剩余额为止)
  112. second, err := svc.UseRechargeGift(ctx, UseRechargeGiftInput{
  113. UserID: user, ActivityID: 400, StudentCourseID: 5002, Amount: mustAmount(t, "90.00"),
  114. })
  115. if err != nil {
  116. t.Fatalf("第二次使用失败: %v", err)
  117. }
  118. if second.UsedAmount.Cents() != 7000 || second.Remaining.Cents() != 0 {
  119. t.Fatalf("第二次应被截到 7000 分、剩余 0,实得 %d/%d",
  120. second.UsedAmount.Cents(), second.Remaining.Cents())
  121. }
  122. // 第三次:额度已用完 → no-op(不写流水)
  123. third, err := svc.UseRechargeGift(ctx, UseRechargeGiftInput{
  124. UserID: user, ActivityID: 400, StudentCourseID: 5003, Amount: mustAmount(t, "10.00"),
  125. })
  126. if err != nil {
  127. t.Fatalf("第三次使用失败: %v", err)
  128. }
  129. if third.UsedAmount.Cents() != 0 {
  130. t.Fatalf("额度用完后应为 no-op,实得 %d 分", third.UsedAmount.Cents())
  131. }
  132. // 横切恒等式:报名行已用额 == -(Σ used_log)
  133. used := readSignedUsed(t, db, user, 400)
  134. if used != 10000 {
  135. t.Fatalf("报名行已用额应 10000 分,实得 %d", used)
  136. }
  137. if sum := sumUsedLogAmount(t, db, 400, user); sum != -used {
  138. t.Fatalf("恒等式破式:signed.used=%d 应等于 -Σ(used_log)=%d", used, -sum)
  139. }
  140. }
  141. // TestUseRechargeGiftRequiresAttendedStatus 报名行状态非 2(未参加)→ no-op(dump 游标条件)。
  142. func TestUseRechargeGiftRequiresAttendedStatus(t *testing.T) {
  143. db := newActivityDomainDB(t)
  144. svc := NewActivityDiscountService(db, fundSuperScope())
  145. ctx := context.Background()
  146. user := fundUserID(21)
  147. seedActivityFull(t, db, 401, model.ActivityTypeRecharge, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
  148. seedSignedFull(t, db, user, 401, model.ActivitySignedSigned, "100.00", "0.00")
  149. res, err := svc.UseRechargeGift(ctx, UseRechargeGiftInput{
  150. UserID: user, ActivityID: 401, StudentCourseID: 5004, Amount: mustAmount(t, "10.00"),
  151. })
  152. if err != nil {
  153. t.Fatalf("使用充值赠送失败: %v", err)
  154. }
  155. if res.UsedAmount.Cents() != 0 {
  156. t.Fatalf("报名行 status!=2 时必须 no-op,实得 %d 分", res.UsedAmount.Cents())
  157. }
  158. if used := readSignedUsed(t, db, user, 401); used != 0 {
  159. t.Fatalf("报名行已用额应保持 0,实得 %d", used)
  160. }
  161. if sum := sumUsedLogAmount(t, db, 401, user); sum != 0 {
  162. t.Fatalf("不得写 used_log,实得合计 %d", sum)
  163. }
  164. }
  165. // ---------------------------------------------------------------- 充值赠送:退回(SP13)
  166. // TestRefundRechargeGiftAllocatesDescending **降序分配**:先冲回最近一笔(50),再 70。
  167. func TestRefundRechargeGiftAllocatesDescending(t *testing.T) {
  168. db := newActivityDomainDB(t)
  169. svc := NewActivityDiscountService(db, fundSuperScope())
  170. ctx := context.Background()
  171. user := fundUserID(22)
  172. seedActivityFull(t, db, 402, model.ActivityTypeRecharge, "200.00", 1, 10, 0, model.ActivityStatusEnabled)
  173. seedSignedFull(t, db, user, 402, model.ActivitySignedAttended, "200.00", "150.00")
  174. // id 小的是**较早**的一笔(-100),id 大的是最近一笔(-50)
  175. seedUsedLogRow(t, db, 402, user, "6001", "-100.00")
  176. seedUsedLogRow(t, db, 402, user, "6001", "-50.00")
  177. res, err := svc.RefundRechargeGift(ctx, RefundRechargeGiftInput{
  178. StudentCourseID: 6001, Amount: mustAmount(t, "120.00"),
  179. })
  180. if err != nil {
  181. t.Fatalf("退回充值赠送失败: %v", err)
  182. }
  183. if res.RefundedTotal.Cents() != 12000 {
  184. t.Fatalf("应退 12000 分,实得 %d", res.RefundedTotal.Cents())
  185. }
  186. if len(res.Items) != 2 {
  187. t.Fatalf("应命中 2 笔,实得 %d 笔(%+v)", len(res.Items), res.Items)
  188. }
  189. // ★ 顺序断言:先 50(最近一笔),再 70(差额补到较早那笔)
  190. if res.Items[0].Amount.Cents() != 5000 {
  191. t.Fatalf("第一笔应为 5000 分(后入先退),实得 %d", res.Items[0].Amount.Cents())
  192. }
  193. if res.Items[1].Amount.Cents() != 7000 {
  194. t.Fatalf("第二笔应为 7000 分,实得 %d", res.Items[1].Amount.Cents())
  195. }
  196. // 横切恒等式:use(+150) 与 refund(-120) 之后 signed.used 应为 30
  197. if used := readSignedUsed(t, db, user, 402); used != 3000 {
  198. t.Fatalf("退回后报名行已用额应 3000 分,实得 %d", used)
  199. }
  200. if sum := sumUsedLogAmount(t, db, 402, user); sum != -3000 {
  201. t.Fatalf("恒等式破式:signed.used=3000 应等于 -Σ(used_log)=%d", -sum)
  202. }
  203. }
  204. // TestRefundRechargeGiftCapsAtRequested 退 40 只退 40(盖上"请求额")。
  205. func TestRefundRechargeGiftCapsAtRequested(t *testing.T) {
  206. db := newActivityDomainDB(t)
  207. svc := NewActivityDiscountService(db, fundSuperScope())
  208. ctx := context.Background()
  209. user := fundUserID(23)
  210. seedActivityFull(t, db, 403, model.ActivityTypeRecharge, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
  211. seedSignedFull(t, db, user, 403, model.ActivitySignedAttended, "100.00", "100.00")
  212. seedUsedLogRow(t, db, 403, user, "6002", "-100.00")
  213. res, err := svc.RefundRechargeGift(ctx, RefundRechargeGiftInput{
  214. StudentCourseID: 6002, Amount: mustAmount(t, "40.00"),
  215. })
  216. if err != nil {
  217. t.Fatalf("退回充值赠送失败: %v", err)
  218. }
  219. if res.RefundedTotal.Cents() != 4000 {
  220. t.Fatalf("只应退 4000 分,实得 %d", res.RefundedTotal.Cents())
  221. }
  222. if len(res.Items) != 1 || res.Items[0].Amount.Cents() != 4000 {
  223. t.Fatalf("应 1 笔 4000 分,实得 %+v", res.Items)
  224. }
  225. if used := readSignedUsed(t, db, user, 403); used != 6000 {
  226. t.Fatalf("退回 40 后已用额应 6000 分,实得 %d", used)
  227. }
  228. }
  229. // TestRefundRechargeGiftSkipsNonRechargeActivities 非 type=2 活动的流水不参与冲回(dump `IF type=2`)。
  230. func TestRefundRechargeGiftSkipsNonRechargeActivities(t *testing.T) {
  231. db := newActivityDomainDB(t)
  232. svc := NewActivityDiscountService(db, fundSuperScope())
  233. ctx := context.Background()
  234. user := fundUserID(24)
  235. seedActivityFull(t, db, 404, model.ActivityTypeRecommend, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
  236. seedSignedFull(t, db, user, 404, model.ActivitySignedAttended, "100.00", "100.00")
  237. seedUsedLogRow(t, db, 404, user, "6003", "-100.00")
  238. res, err := svc.RefundRechargeGift(ctx, RefundRechargeGiftInput{
  239. StudentCourseID: 6003, Amount: mustAmount(t, "100.00"),
  240. })
  241. if err != nil {
  242. t.Fatalf("退回充值赠送失败: %v", err)
  243. }
  244. if res.RefundedTotal.Cents() != 0 || len(res.Items) != 0 {
  245. t.Fatalf("非充值赠送活动不得冲回,实得 %+v", res.Items)
  246. }
  247. if used := readSignedUsed(t, db, user, 404); used != 10000 {
  248. t.Fatalf("已用额应保持 10000 分,实得 %d", used)
  249. }
  250. }
  251. // ---------------------------------------------------------------- 代金券:使用(SP15)
  252. // TestUseVoucherType3UsesFaceValue 代金券按 dump 口径取**券面额**(不是请求额、也不是剩余额)。
  253. func TestUseVoucherType3UsesFaceValue(t *testing.T) {
  254. db := newActivityDomainDB(t)
  255. svc := NewActivityDiscountService(db, fundSuperScope())
  256. ctx := context.Background()
  257. user := fundUserID(25)
  258. seedActivityFull(t, db, 405, model.ActivityTypeVoucher, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
  259. poolID := seedPool(t, db, user, 405, "100.00", "0.00")
  260. res, err := svc.UseVoucher(ctx, UseVoucherInput{
  261. UserID: user, ActivityID: 405, StudentCourseID: 7001, Amount: mustAmount(t, "30.00"),
  262. })
  263. if err != nil {
  264. t.Fatalf("使用代金券失败: %v", err)
  265. }
  266. if res.Applied.Cents() != 10000 {
  267. t.Fatalf("代金券应按面额 10000 分占用,实得 %d", res.Applied.Cents())
  268. }
  269. if got := readPoolUsed(t, db, poolID); got != 10000 {
  270. t.Fatalf("池已用额应 10000 分,实得 %d", got)
  271. }
  272. // 横切恒等式:pool.used == -Σ(discount_log)
  273. if sum := sumDiscountLogAmount(t, db, poolID); sum != -10000 {
  274. t.Fatalf("恒等式破式:pool.used=10000 应等于 -Σ(discount_log)=%d", -sum)
  275. }
  276. }
  277. // TestUseVoucherRejectsInsufficientWith3109 可用额不足 → 3109(dump 静默 LEAVE,Go 显式)。
  278. func TestUseVoucherRejectsInsufficientWith3109(t *testing.T) {
  279. db := newActivityDomainDB(t)
  280. svc := NewActivityDiscountService(db, fundSuperScope())
  281. ctx := context.Background()
  282. user := fundUserID(26)
  283. seedActivityFull(t, db, 406, model.ActivityTypeVoucher, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
  284. poolID := seedPool(t, db, user, 406, "100.00", "90.00") // 只剩 10
  285. _, err := svc.UseVoucher(ctx, UseVoucherInput{
  286. UserID: user, ActivityID: 406, StudentCourseID: 7002, Amount: mustAmount(t, "20.00"),
  287. })
  288. wantAppErr(t, err, apperr.CodeGiftAmountShort, "可用赠送金额不足")
  289. if got := readPoolUsed(t, db, poolID); got != 9000 {
  290. t.Fatalf("被 3109 拒绝后池已用额不得变,实得 %d", got)
  291. }
  292. // 被拒后**零副作用**:池已用额与流水条数都不变(种子池本身没有流水行 → 合计应为 0)
  293. if sum := sumDiscountLogAmount(t, db, poolID); sum != 0 {
  294. t.Fatalf("被 3109 拒绝后不得写 discount_log,实得合计 %d", sum)
  295. }
  296. }
  297. // ---------------------------------------------------------------- 代金券/赠送:按报课记录退回(SP16)
  298. // TestRefundByStuCourseDescendingWithCap 降序(50 → 70)+ 盖帽(退 40 只退 40)。
  299. func TestRefundByStuCourseDescendingWithCap(t *testing.T) {
  300. db := newActivityDomainDB(t)
  301. svc := NewActivityDiscountService(db, fundSuperScope())
  302. ctx := context.Background()
  303. user := fundUserID(27)
  304. seedActivityFull(t, db, 407, model.ActivityTypeVoucher, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
  305. poolID := seedPool(t, db, user, 407, "150.00", "150.00")
  306. // 两次用券:先 100、后 50(同一报课记录)→ 池 used = 150
  307. seedDiscountLogRow(t, db, poolID, 8001, "-100.00")
  308. seedDiscountLogRow(t, db, poolID, 8001, "-50.00")
  309. res, err := svc.RefundByStuCourse(ctx, RefundByStuCourseInput{
  310. StudentCourseID: 8001, Amount: mustAmount(t, "120.00"),
  311. })
  312. if err != nil {
  313. t.Fatalf("按报课记录退回失败: %v", err)
  314. }
  315. if res.RefundedTotal.Cents() != 12000 {
  316. t.Fatalf("应退 12000 分,实得 %d", res.RefundedTotal.Cents())
  317. }
  318. if len(res.Items) != 2 || res.Items[0].Amount.Cents() != 5000 || res.Items[1].Amount.Cents() != 7000 {
  319. t.Fatalf("降序分配应为 5000→7000,实得 %+v", res.Items)
  320. }
  321. if got := readPoolUsed(t, db, poolID); got != 3000 {
  322. t.Fatalf("退回后池已用额应 3000 分,实得 %d", got)
  323. }
  324. if sum := sumDiscountLogAmount(t, db, poolID); sum != -3000 {
  325. t.Fatalf("恒等式破式:pool.used=3000 应等于 -Σ(discount_log)=%d", -sum)
  326. }
  327. }
  328. // TestRefundByStuCourseCapsAtRequested 退 40 只退 40。
  329. func TestRefundByStuCourseCapsAtRequested(t *testing.T) {
  330. db := newActivityDomainDB(t)
  331. svc := NewActivityDiscountService(db, fundSuperScope())
  332. ctx := context.Background()
  333. user := fundUserID(28)
  334. seedActivityFull(t, db, 408, model.ActivityTypeVoucher, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
  335. poolID := seedPool(t, db, user, 408, "100.00", "100.00")
  336. seedDiscountLogRow(t, db, poolID, 8002, "-100.00")
  337. res, err := svc.RefundByStuCourse(ctx, RefundByStuCourseInput{
  338. StudentCourseID: 8002, Amount: mustAmount(t, "40.00"),
  339. })
  340. if err != nil {
  341. t.Fatalf("按报课记录退回失败: %v", err)
  342. }
  343. if res.RefundedTotal.Cents() != 4000 || len(res.Items) != 1 {
  344. t.Fatalf("应退 4000 分 1 笔,实得 %d 分 %d 笔", res.RefundedTotal.Cents(), len(res.Items))
  345. }
  346. if got := readPoolUsed(t, db, poolID); got != 6000 {
  347. t.Fatalf("池已用额应 6000 分,实得 %d", got)
  348. }
  349. }
  350. // ---------------------------------------------------------------- 代金券:按活动退回(SP17)
  351. // TestRefundByActivityVoucherFullRefund 代金券一次全额退(amount 置为券面额)。
  352. func TestRefundByActivityVoucherFullRefund(t *testing.T) {
  353. db := newActivityDomainDB(t)
  354. svc := NewActivityDiscountService(db, fundSuperScope())
  355. ctx := context.Background()
  356. user := fundUserID(29)
  357. seedActivityFull(t, db, 409, model.ActivityTypeVoucher, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
  358. poolID := seedPool(t, db, user, 409, "100.00", "100.00")
  359. seedDiscountLogRow(t, db, poolID, 9001, "-100.00")
  360. // 请求 30,但代金券一次全额退 → 实际退 100(再按最近一条消费额 100 盖帽)
  361. res, err := svc.RefundByActivity(ctx, RefundByActivityInput{
  362. UserID: user, ActivityID: 409, Amount: mustAmount(t, "30.00"),
  363. })
  364. if err != nil {
  365. t.Fatalf("按活动退回失败: %v", err)
  366. }
  367. if res.Refunded.Cents() != 10000 {
  368. t.Fatalf("代金券应一次全额退 10000 分,实得 %d", res.Refunded.Cents())
  369. }
  370. if got := readPoolUsed(t, db, poolID); got != 0 {
  371. t.Fatalf("全额退后池已用额应 0,实得 %d", got)
  372. }
  373. if sum := sumDiscountLogAmount(t, db, poolID); sum != 0 {
  374. t.Fatalf("恒等式破式:pool.used=0 应等于 -Σ(discount_log)=%d", -sum)
  375. }
  376. }
  377. // TestRefundByActivityCappedByLastLog A13 修正:非代金券按"最近一条消费额"盖帽,不得超退。
  378. func TestRefundByActivityCappedByLastLog(t *testing.T) {
  379. db := newActivityDomainDB(t)
  380. svc := NewActivityDiscountService(db, fundSuperScope())
  381. ctx := context.Background()
  382. user := fundUserID(30)
  383. seedActivityFull(t, db, 410, model.ActivityTypeRecharge, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
  384. poolID := seedPool(t, db, user, 410, "100.00", "50.00")
  385. seedDiscountLogRow(t, db, poolID, 9002, "-50.00")
  386. // 请求 80 > 最近一条消费 50 → 只能退 50(dump 的 `SELECT x = y` 从未赋值 ⇒ 会退 80,A13 修正)
  387. res, err := svc.RefundByActivity(ctx, RefundByActivityInput{
  388. UserID: user, ActivityID: 410, Amount: mustAmount(t, "80.00"),
  389. })
  390. if err != nil {
  391. t.Fatalf("按活动退回失败: %v", err)
  392. }
  393. if res.Refunded.Cents() != 5000 {
  394. t.Fatalf("应盖帽到 5000 分,实得 %d", res.Refunded.Cents())
  395. }
  396. if got := readPoolUsed(t, db, poolID); got != 0 {
  397. t.Fatalf("池已用额应 0,实得 %d", got)
  398. }
  399. if sum := sumDiscountLogAmount(t, db, poolID); sum != 0 {
  400. t.Fatalf("恒等式破式:pool.used=0 应等于 -Σ(discount_log)=%d", -sum)
  401. }
  402. }
  403. // TestRefundByActivityNoLogIsNoop 无消费流水 → no-op(dump LIMIT 1 空)。
  404. func TestRefundByActivityNoLogIsNoop(t *testing.T) {
  405. db := newActivityDomainDB(t)
  406. svc := NewActivityDiscountService(db, fundSuperScope())
  407. ctx := context.Background()
  408. user := fundUserID(31)
  409. seedActivityFull(t, db, 411, model.ActivityTypeVoucher, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
  410. poolID := seedPool(t, db, user, 411, "100.00", "0.00")
  411. res, err := svc.RefundByActivity(ctx, RefundByActivityInput{
  412. UserID: user, ActivityID: 411, Amount: mustAmount(t, "100.00"),
  413. })
  414. if err != nil {
  415. t.Fatalf("按活动退回失败: %v", err)
  416. }
  417. if res.Refunded.Cents() != 0 {
  418. t.Fatalf("无消费流水应 no-op,实得 %d", res.Refunded.Cents())
  419. }
  420. if got := readPoolUsed(t, db, poolID); got != 0 {
  421. t.Fatalf("池已用额应保持 0,实得 %d", got)
  422. }
  423. }
  424. // ---------------------------------------------------------------- 可用额汇总(SP14)
  425. // TestUsableAmountAggregatesAndRespectsScope 可用额 = Σ(face−used)(仅 face>used 的行),
  426. // 且 `institutionID>0` 时校区条件是**等值**(campus_id=0 的机构级池只在 campusID=0 时命中)。
  427. func TestUsableAmountAggregatesAndRespectsScope(t *testing.T) {
  428. db := newActivityDomainDB(t)
  429. svc := NewActivityDiscountService(db, fundSuperScope())
  430. ctx := context.Background()
  431. user := fundUserID(32)
  432. seedActivityFull(t, db, 412, model.ActivityTypeVoucher, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
  433. seedPool(t, db, user, 412, "100.00", "30.00") // 本校区:可用 70
  434. // 机构级池(campus_id=0):可用 10
  435. if err := db.Create(&model.CoursesActivityUserDiscount{
  436. ActivityID: 412, InstitutionID: 1, CampusID: 0, UserID: user,
  437. DiscountAmount: "10.00", DiscountAmountUsed: "0.00",
  438. }).Error; err != nil {
  439. t.Fatalf("seed 机构级池失败: %v", err)
  440. }
  441. // 已用尽的行不参与(face == used)
  442. seedPool(t, db, user, 412, "50.00", "50.00")
  443. // campusID=10:只命中本校区池 → 70
  444. res, err := svc.UsableAmount(ctx, UsableAmountInput{UserID: user, InstitutionID: 1, CampusID: 10})
  445. if err != nil {
  446. t.Fatalf("汇总可用额失败: %v", err)
  447. }
  448. if res.Usable.Cents() != 7000 {
  449. t.Fatalf("本校区可用额应 7000 分(等值过滤,不含机构级池),实得 %d", res.Usable.Cents())
  450. }
  451. // campusID=0:只命中机构级池 → 10
  452. res, err = svc.UsableAmount(ctx, UsableAmountInput{UserID: user, InstitutionID: 1, CampusID: 0})
  453. if err != nil {
  454. t.Fatalf("汇总可用额失败: %v", err)
  455. }
  456. if res.Usable.Cents() != 1000 {
  457. t.Fatalf("机构级池可用额应 1000 分,实得 %d", res.Usable.Cents())
  458. }
  459. // institutionID=0:平台视角,不加任何作用域条件 → 70+10=80
  460. res, err = svc.UsableAmount(ctx, UsableAmountInput{UserID: user})
  461. if err != nil {
  462. t.Fatalf("汇总可用额失败: %v", err)
  463. }
  464. if res.Usable.Cents() != 8000 {
  465. t.Fatalf("平台视角可用额应 8000 分,实得 %d", res.Usable.Cents())
  466. }
  467. }
  468. // seedDiscountLogRow 落一行 discount_log(负=消费/正=退回)。
  469. func seedDiscountLogRow(t *testing.T, db *gorm.DB, poolID int64, stuCourseID int64, amount string) {
  470. t.Helper()
  471. row := &model.CoursesActivityUserDiscountLog{
  472. ActivityUserDiscountID: poolID,
  473. CoursesStudentID: &stuCourseID,
  474. DiscountAmountUsed: amount,
  475. }
  476. if err := db.Create(row).Error; err != nil {
  477. t.Fatalf("seed discount_log 失败: %v", err)
  478. }
  479. }
  480. // 编译期断言:避免 money 包被"只用类型"时误删导入。
  481. var _ = money.FromCents