| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545 |
- // ActivityDiscountService 单测(AC-07):充值赠送使用/退回 + 代金券使用/退回 + 可用额。
- //
- // 断言纪律(对照 §5.1 第 4 条「横切优先」——**不是**数 case 数量):
- // 1. **退回分配顺序(降序)是阻断级**:两次用券 100/50、退 120 ⇒ **先 50 再 70**。
- // 若把 `id DESC` 写成 `ASC`,总额同样是 120,只有"逐条明细"能证明分配错误 ⇒
- // 本文件断言 `Items` 的顺序与逐条金额,而不只断言合计。
- // 2. **盖帽**:退 40 只能退 40(单行冲回额 = min(|行金额|, 剩余应退))。
- // 3. **账务恒等式(自证失败式)**:`signed.activity_money_used == -Σ(used_log)`、
- // `pool.discount_amount_used == -Σ(discount_log)`——任何"少写一条流水/金额算错"都会立刻破式。
- // 4. **代金券一次全额退**:type=3 的退回额 = 券面额,再按最近一条消费额盖帽(A13 修正)。
- package service
- import (
- "context"
- "testing"
- "gorm.io/gorm"
- "yuansiwei/unified-backend/internal/academic/apperr"
- "yuansiwei/unified-backend/internal/academic/model"
- "yuansiwei/unified-backend/internal/academic/money"
- )
- // ---------------------------------------------------------------- 辅助
- // seedSignedFull 落一行报名行(可指定状态与两列金额)。
- func seedSignedFull(t *testing.T, db *gorm.DB, userID string, activityID int64, status int32,
- activityMoney, activityMoneyUsed string) {
- t.Helper()
- row := &model.CoursesActivitySigned{
- ActivityID: activityID, UserID: userID, InstitutionID: 1, CampusID: 10,
- Status: status, ActivityMoney: activityMoney, ActivityMoneyUsed: activityMoneyUsed,
- }
- if err := db.Create(row).Error; err != nil {
- t.Fatalf("seed 报名行失败: %v", err)
- }
- }
- // seedPool 落一行赠送池(返回池 ID)。
- func seedPool(t *testing.T, db *gorm.DB, userID string, activityID int64,
- discountAmount, discountAmountUsed string) int64 {
- t.Helper()
- row := &model.CoursesActivityUserDiscount{
- ActivityID: activityID, InstitutionID: 1, CampusID: 10, UserID: userID,
- DiscountAmount: discountAmount, DiscountAmountUsed: discountAmountUsed,
- }
- if err := db.Create(row).Error; err != nil {
- t.Fatalf("seed 赠送池失败: %v", err)
- }
- return row.ID
- }
- // readSignedUsed 读报名行已用额(分)。
- func readSignedUsed(t *testing.T, db *gorm.DB, userID string, activityID int64) int64 {
- t.Helper()
- row := readSignedRow(t, db, userID, activityID)
- if row == nil {
- t.Fatalf("报名行不存在(user=%s,activity=%d)", userID, activityID)
- }
- return cents(t, row.ActivityMoneyUsed)
- }
- // sumUsedLogAmount 汇总某活动某用户的 used_log 金额(分;消费为负、退回为正)。
- func sumUsedLogAmount(t *testing.T, db *gorm.DB, activityID int64, userID string) int64 {
- t.Helper()
- var rows []model.CoursesActivityUsedLog
- if err := db.Where("activity_id = ? AND user_id = ?", activityID, userID).Find(&rows).Error; err != nil {
- t.Fatalf("读 used_log 失败: %v", err)
- }
- total := int64(0)
- for i := range rows {
- total += cents(t, rows[i].ActivityMoneyUsed)
- }
- return total
- }
- // readPoolUsed 读池已用额(分)。
- func readPoolUsed(t *testing.T, db *gorm.DB, poolID int64) int64 {
- t.Helper()
- var row model.CoursesActivityUserDiscount
- if err := db.Where("id = ?", poolID).Take(&row).Error; err != nil {
- t.Fatalf("读赠送池(%d) 失败: %v", poolID, err)
- }
- return cents(t, row.DiscountAmountUsed)
- }
- // sumDiscountLogAmount 汇总某池的 discount_log 金额(分;消费为负、退回为正)。
- func sumDiscountLogAmount(t *testing.T, db *gorm.DB, poolID int64) int64 {
- t.Helper()
- var rows []model.CoursesActivityUserDiscountLog
- if err := db.Where("activity_user_discount_id = ?", poolID).Find(&rows).Error; err != nil {
- t.Fatalf("读 discount_log 失败: %v", err)
- }
- total := int64(0)
- for i := range rows {
- total += cents(t, rows[i].DiscountAmountUsed)
- }
- return total
- }
- // ---------------------------------------------------------------- 充值赠送:使用(SP12)
- // TestUseRechargeGiftCapsToRemainingAndKeepsLedgerIdentity
- // 使用额不得超过活动剩余额(A9 修正),且 `signed.used == -Σ used_log` 恒成立。
- func TestUseRechargeGiftCapsToRemainingAndKeepsLedgerIdentity(t *testing.T) {
- db := newActivityDomainDB(t)
- svc := NewActivityDiscountService(db, fundSuperScope())
- ctx := context.Background()
- user := fundUserID(20)
- seedActivityFull(t, db, 400, model.ActivityTypeRecharge, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
- seedSignedFull(t, db, user, 400, model.ActivitySignedAttended, "100.00", "0.00")
- // 第一次:用 30(小于剩余 100)
- first, err := svc.UseRechargeGift(ctx, UseRechargeGiftInput{
- UserID: user, ActivityID: 400, StudentCourseID: 5001, Amount: mustAmount(t, "30.00"),
- })
- if err != nil {
- t.Fatalf("使用充值赠送失败: %v", err)
- }
- if first.UsedAmount.Cents() != 3000 || first.Remaining.Cents() != 7000 {
- t.Fatalf("首用应 3000 分、剩余 7000 分,实得 %d/%d",
- first.UsedAmount.Cents(), first.Remaining.Cents())
- }
- // 第二次:请求 90,但只剩 70 → 只能扣 70(A9:逐行递减到剩余额为止)
- second, err := svc.UseRechargeGift(ctx, UseRechargeGiftInput{
- UserID: user, ActivityID: 400, StudentCourseID: 5002, Amount: mustAmount(t, "90.00"),
- })
- if err != nil {
- t.Fatalf("第二次使用失败: %v", err)
- }
- if second.UsedAmount.Cents() != 7000 || second.Remaining.Cents() != 0 {
- t.Fatalf("第二次应被截到 7000 分、剩余 0,实得 %d/%d",
- second.UsedAmount.Cents(), second.Remaining.Cents())
- }
- // 第三次:额度已用完 → no-op(不写流水)
- third, err := svc.UseRechargeGift(ctx, UseRechargeGiftInput{
- UserID: user, ActivityID: 400, StudentCourseID: 5003, Amount: mustAmount(t, "10.00"),
- })
- if err != nil {
- t.Fatalf("第三次使用失败: %v", err)
- }
- if third.UsedAmount.Cents() != 0 {
- t.Fatalf("额度用完后应为 no-op,实得 %d 分", third.UsedAmount.Cents())
- }
- // 横切恒等式:报名行已用额 == -(Σ used_log)
- used := readSignedUsed(t, db, user, 400)
- if used != 10000 {
- t.Fatalf("报名行已用额应 10000 分,实得 %d", used)
- }
- if sum := sumUsedLogAmount(t, db, 400, user); sum != -used {
- t.Fatalf("恒等式破式:signed.used=%d 应等于 -Σ(used_log)=%d", used, -sum)
- }
- }
- // TestUseRechargeGiftRequiresAttendedStatus 报名行状态非 2(未参加)→ no-op(dump 游标条件)。
- func TestUseRechargeGiftRequiresAttendedStatus(t *testing.T) {
- db := newActivityDomainDB(t)
- svc := NewActivityDiscountService(db, fundSuperScope())
- ctx := context.Background()
- user := fundUserID(21)
- seedActivityFull(t, db, 401, model.ActivityTypeRecharge, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
- seedSignedFull(t, db, user, 401, model.ActivitySignedSigned, "100.00", "0.00")
- res, err := svc.UseRechargeGift(ctx, UseRechargeGiftInput{
- UserID: user, ActivityID: 401, StudentCourseID: 5004, Amount: mustAmount(t, "10.00"),
- })
- if err != nil {
- t.Fatalf("使用充值赠送失败: %v", err)
- }
- if res.UsedAmount.Cents() != 0 {
- t.Fatalf("报名行 status!=2 时必须 no-op,实得 %d 分", res.UsedAmount.Cents())
- }
- if used := readSignedUsed(t, db, user, 401); used != 0 {
- t.Fatalf("报名行已用额应保持 0,实得 %d", used)
- }
- if sum := sumUsedLogAmount(t, db, 401, user); sum != 0 {
- t.Fatalf("不得写 used_log,实得合计 %d", sum)
- }
- }
- // ---------------------------------------------------------------- 充值赠送:退回(SP13)
- // TestRefundRechargeGiftAllocatesDescending **降序分配**:先冲回最近一笔(50),再 70。
- func TestRefundRechargeGiftAllocatesDescending(t *testing.T) {
- db := newActivityDomainDB(t)
- svc := NewActivityDiscountService(db, fundSuperScope())
- ctx := context.Background()
- user := fundUserID(22)
- seedActivityFull(t, db, 402, model.ActivityTypeRecharge, "200.00", 1, 10, 0, model.ActivityStatusEnabled)
- seedSignedFull(t, db, user, 402, model.ActivitySignedAttended, "200.00", "150.00")
- // id 小的是**较早**的一笔(-100),id 大的是最近一笔(-50)
- seedUsedLogRow(t, db, 402, user, "6001", "-100.00")
- seedUsedLogRow(t, db, 402, user, "6001", "-50.00")
- res, err := svc.RefundRechargeGift(ctx, RefundRechargeGiftInput{
- StudentCourseID: 6001, Amount: mustAmount(t, "120.00"),
- })
- if err != nil {
- t.Fatalf("退回充值赠送失败: %v", err)
- }
- if res.RefundedTotal.Cents() != 12000 {
- t.Fatalf("应退 12000 分,实得 %d", res.RefundedTotal.Cents())
- }
- if len(res.Items) != 2 {
- t.Fatalf("应命中 2 笔,实得 %d 笔(%+v)", len(res.Items), res.Items)
- }
- // ★ 顺序断言:先 50(最近一笔),再 70(差额补到较早那笔)
- if res.Items[0].Amount.Cents() != 5000 {
- t.Fatalf("第一笔应为 5000 分(后入先退),实得 %d", res.Items[0].Amount.Cents())
- }
- if res.Items[1].Amount.Cents() != 7000 {
- t.Fatalf("第二笔应为 7000 分,实得 %d", res.Items[1].Amount.Cents())
- }
- // 横切恒等式:use(+150) 与 refund(-120) 之后 signed.used 应为 30
- if used := readSignedUsed(t, db, user, 402); used != 3000 {
- t.Fatalf("退回后报名行已用额应 3000 分,实得 %d", used)
- }
- if sum := sumUsedLogAmount(t, db, 402, user); sum != -3000 {
- t.Fatalf("恒等式破式:signed.used=3000 应等于 -Σ(used_log)=%d", -sum)
- }
- }
- // TestRefundRechargeGiftCapsAtRequested 退 40 只退 40(盖上"请求额")。
- func TestRefundRechargeGiftCapsAtRequested(t *testing.T) {
- db := newActivityDomainDB(t)
- svc := NewActivityDiscountService(db, fundSuperScope())
- ctx := context.Background()
- user := fundUserID(23)
- seedActivityFull(t, db, 403, model.ActivityTypeRecharge, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
- seedSignedFull(t, db, user, 403, model.ActivitySignedAttended, "100.00", "100.00")
- seedUsedLogRow(t, db, 403, user, "6002", "-100.00")
- res, err := svc.RefundRechargeGift(ctx, RefundRechargeGiftInput{
- StudentCourseID: 6002, Amount: mustAmount(t, "40.00"),
- })
- if err != nil {
- t.Fatalf("退回充值赠送失败: %v", err)
- }
- if res.RefundedTotal.Cents() != 4000 {
- t.Fatalf("只应退 4000 分,实得 %d", res.RefundedTotal.Cents())
- }
- if len(res.Items) != 1 || res.Items[0].Amount.Cents() != 4000 {
- t.Fatalf("应 1 笔 4000 分,实得 %+v", res.Items)
- }
- if used := readSignedUsed(t, db, user, 403); used != 6000 {
- t.Fatalf("退回 40 后已用额应 6000 分,实得 %d", used)
- }
- }
- // TestRefundRechargeGiftSkipsNonRechargeActivities 非 type=2 活动的流水不参与冲回(dump `IF type=2`)。
- func TestRefundRechargeGiftSkipsNonRechargeActivities(t *testing.T) {
- db := newActivityDomainDB(t)
- svc := NewActivityDiscountService(db, fundSuperScope())
- ctx := context.Background()
- user := fundUserID(24)
- seedActivityFull(t, db, 404, model.ActivityTypeRecommend, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
- seedSignedFull(t, db, user, 404, model.ActivitySignedAttended, "100.00", "100.00")
- seedUsedLogRow(t, db, 404, user, "6003", "-100.00")
- res, err := svc.RefundRechargeGift(ctx, RefundRechargeGiftInput{
- StudentCourseID: 6003, Amount: mustAmount(t, "100.00"),
- })
- if err != nil {
- t.Fatalf("退回充值赠送失败: %v", err)
- }
- if res.RefundedTotal.Cents() != 0 || len(res.Items) != 0 {
- t.Fatalf("非充值赠送活动不得冲回,实得 %+v", res.Items)
- }
- if used := readSignedUsed(t, db, user, 404); used != 10000 {
- t.Fatalf("已用额应保持 10000 分,实得 %d", used)
- }
- }
- // ---------------------------------------------------------------- 代金券:使用(SP15)
- // TestUseVoucherType3UsesFaceValue 代金券按 dump 口径取**券面额**(不是请求额、也不是剩余额)。
- func TestUseVoucherType3UsesFaceValue(t *testing.T) {
- db := newActivityDomainDB(t)
- svc := NewActivityDiscountService(db, fundSuperScope())
- ctx := context.Background()
- user := fundUserID(25)
- seedActivityFull(t, db, 405, model.ActivityTypeVoucher, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
- poolID := seedPool(t, db, user, 405, "100.00", "0.00")
- res, err := svc.UseVoucher(ctx, UseVoucherInput{
- UserID: user, ActivityID: 405, StudentCourseID: 7001, Amount: mustAmount(t, "30.00"),
- })
- if err != nil {
- t.Fatalf("使用代金券失败: %v", err)
- }
- if res.Applied.Cents() != 10000 {
- t.Fatalf("代金券应按面额 10000 分占用,实得 %d", res.Applied.Cents())
- }
- if got := readPoolUsed(t, db, poolID); got != 10000 {
- t.Fatalf("池已用额应 10000 分,实得 %d", got)
- }
- // 横切恒等式:pool.used == -Σ(discount_log)
- if sum := sumDiscountLogAmount(t, db, poolID); sum != -10000 {
- t.Fatalf("恒等式破式:pool.used=10000 应等于 -Σ(discount_log)=%d", -sum)
- }
- }
- // TestUseVoucherRejectsInsufficientWith3109 可用额不足 → 3109(dump 静默 LEAVE,Go 显式)。
- func TestUseVoucherRejectsInsufficientWith3109(t *testing.T) {
- db := newActivityDomainDB(t)
- svc := NewActivityDiscountService(db, fundSuperScope())
- ctx := context.Background()
- user := fundUserID(26)
- seedActivityFull(t, db, 406, model.ActivityTypeVoucher, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
- poolID := seedPool(t, db, user, 406, "100.00", "90.00") // 只剩 10
- _, err := svc.UseVoucher(ctx, UseVoucherInput{
- UserID: user, ActivityID: 406, StudentCourseID: 7002, Amount: mustAmount(t, "20.00"),
- })
- wantAppErr(t, err, apperr.CodeGiftAmountShort, "可用赠送金额不足")
- if got := readPoolUsed(t, db, poolID); got != 9000 {
- t.Fatalf("被 3109 拒绝后池已用额不得变,实得 %d", got)
- }
- // 被拒后**零副作用**:池已用额与流水条数都不变(种子池本身没有流水行 → 合计应为 0)
- if sum := sumDiscountLogAmount(t, db, poolID); sum != 0 {
- t.Fatalf("被 3109 拒绝后不得写 discount_log,实得合计 %d", sum)
- }
- }
- // ---------------------------------------------------------------- 代金券/赠送:按报课记录退回(SP16)
- // TestRefundByStuCourseDescendingWithCap 降序(50 → 70)+ 盖帽(退 40 只退 40)。
- func TestRefundByStuCourseDescendingWithCap(t *testing.T) {
- db := newActivityDomainDB(t)
- svc := NewActivityDiscountService(db, fundSuperScope())
- ctx := context.Background()
- user := fundUserID(27)
- seedActivityFull(t, db, 407, model.ActivityTypeVoucher, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
- poolID := seedPool(t, db, user, 407, "150.00", "150.00")
- // 两次用券:先 100、后 50(同一报课记录)→ 池 used = 150
- seedDiscountLogRow(t, db, poolID, 8001, "-100.00")
- seedDiscountLogRow(t, db, poolID, 8001, "-50.00")
- res, err := svc.RefundByStuCourse(ctx, RefundByStuCourseInput{
- StudentCourseID: 8001, Amount: mustAmount(t, "120.00"),
- })
- if err != nil {
- t.Fatalf("按报课记录退回失败: %v", err)
- }
- if res.RefundedTotal.Cents() != 12000 {
- t.Fatalf("应退 12000 分,实得 %d", res.RefundedTotal.Cents())
- }
- if len(res.Items) != 2 || res.Items[0].Amount.Cents() != 5000 || res.Items[1].Amount.Cents() != 7000 {
- t.Fatalf("降序分配应为 5000→7000,实得 %+v", res.Items)
- }
- if got := readPoolUsed(t, db, poolID); got != 3000 {
- t.Fatalf("退回后池已用额应 3000 分,实得 %d", got)
- }
- if sum := sumDiscountLogAmount(t, db, poolID); sum != -3000 {
- t.Fatalf("恒等式破式:pool.used=3000 应等于 -Σ(discount_log)=%d", -sum)
- }
- }
- // TestRefundByStuCourseCapsAtRequested 退 40 只退 40。
- func TestRefundByStuCourseCapsAtRequested(t *testing.T) {
- db := newActivityDomainDB(t)
- svc := NewActivityDiscountService(db, fundSuperScope())
- ctx := context.Background()
- user := fundUserID(28)
- seedActivityFull(t, db, 408, model.ActivityTypeVoucher, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
- poolID := seedPool(t, db, user, 408, "100.00", "100.00")
- seedDiscountLogRow(t, db, poolID, 8002, "-100.00")
- res, err := svc.RefundByStuCourse(ctx, RefundByStuCourseInput{
- StudentCourseID: 8002, Amount: mustAmount(t, "40.00"),
- })
- if err != nil {
- t.Fatalf("按报课记录退回失败: %v", err)
- }
- if res.RefundedTotal.Cents() != 4000 || len(res.Items) != 1 {
- t.Fatalf("应退 4000 分 1 笔,实得 %d 分 %d 笔", res.RefundedTotal.Cents(), len(res.Items))
- }
- if got := readPoolUsed(t, db, poolID); got != 6000 {
- t.Fatalf("池已用额应 6000 分,实得 %d", got)
- }
- }
- // ---------------------------------------------------------------- 代金券:按活动退回(SP17)
- // TestRefundByActivityVoucherFullRefund 代金券一次全额退(amount 置为券面额)。
- func TestRefundByActivityVoucherFullRefund(t *testing.T) {
- db := newActivityDomainDB(t)
- svc := NewActivityDiscountService(db, fundSuperScope())
- ctx := context.Background()
- user := fundUserID(29)
- seedActivityFull(t, db, 409, model.ActivityTypeVoucher, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
- poolID := seedPool(t, db, user, 409, "100.00", "100.00")
- seedDiscountLogRow(t, db, poolID, 9001, "-100.00")
- // 请求 30,但代金券一次全额退 → 实际退 100(再按最近一条消费额 100 盖帽)
- res, err := svc.RefundByActivity(ctx, RefundByActivityInput{
- UserID: user, ActivityID: 409, Amount: mustAmount(t, "30.00"),
- })
- if err != nil {
- t.Fatalf("按活动退回失败: %v", err)
- }
- if res.Refunded.Cents() != 10000 {
- t.Fatalf("代金券应一次全额退 10000 分,实得 %d", res.Refunded.Cents())
- }
- if got := readPoolUsed(t, db, poolID); got != 0 {
- t.Fatalf("全额退后池已用额应 0,实得 %d", got)
- }
- if sum := sumDiscountLogAmount(t, db, poolID); sum != 0 {
- t.Fatalf("恒等式破式:pool.used=0 应等于 -Σ(discount_log)=%d", -sum)
- }
- }
- // TestRefundByActivityCappedByLastLog A13 修正:非代金券按"最近一条消费额"盖帽,不得超退。
- func TestRefundByActivityCappedByLastLog(t *testing.T) {
- db := newActivityDomainDB(t)
- svc := NewActivityDiscountService(db, fundSuperScope())
- ctx := context.Background()
- user := fundUserID(30)
- seedActivityFull(t, db, 410, model.ActivityTypeRecharge, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
- poolID := seedPool(t, db, user, 410, "100.00", "50.00")
- seedDiscountLogRow(t, db, poolID, 9002, "-50.00")
- // 请求 80 > 最近一条消费 50 → 只能退 50(dump 的 `SELECT x = y` 从未赋值 ⇒ 会退 80,A13 修正)
- res, err := svc.RefundByActivity(ctx, RefundByActivityInput{
- UserID: user, ActivityID: 410, Amount: mustAmount(t, "80.00"),
- })
- if err != nil {
- t.Fatalf("按活动退回失败: %v", err)
- }
- if res.Refunded.Cents() != 5000 {
- t.Fatalf("应盖帽到 5000 分,实得 %d", res.Refunded.Cents())
- }
- if got := readPoolUsed(t, db, poolID); got != 0 {
- t.Fatalf("池已用额应 0,实得 %d", got)
- }
- if sum := sumDiscountLogAmount(t, db, poolID); sum != 0 {
- t.Fatalf("恒等式破式:pool.used=0 应等于 -Σ(discount_log)=%d", -sum)
- }
- }
- // TestRefundByActivityNoLogIsNoop 无消费流水 → no-op(dump LIMIT 1 空)。
- func TestRefundByActivityNoLogIsNoop(t *testing.T) {
- db := newActivityDomainDB(t)
- svc := NewActivityDiscountService(db, fundSuperScope())
- ctx := context.Background()
- user := fundUserID(31)
- seedActivityFull(t, db, 411, model.ActivityTypeVoucher, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
- poolID := seedPool(t, db, user, 411, "100.00", "0.00")
- res, err := svc.RefundByActivity(ctx, RefundByActivityInput{
- UserID: user, ActivityID: 411, Amount: mustAmount(t, "100.00"),
- })
- if err != nil {
- t.Fatalf("按活动退回失败: %v", err)
- }
- if res.Refunded.Cents() != 0 {
- t.Fatalf("无消费流水应 no-op,实得 %d", res.Refunded.Cents())
- }
- if got := readPoolUsed(t, db, poolID); got != 0 {
- t.Fatalf("池已用额应保持 0,实得 %d", got)
- }
- }
- // ---------------------------------------------------------------- 可用额汇总(SP14)
- // TestUsableAmountAggregatesAndRespectsScope 可用额 = Σ(face−used)(仅 face>used 的行),
- // 且 `institutionID>0` 时校区条件是**等值**(campus_id=0 的机构级池只在 campusID=0 时命中)。
- func TestUsableAmountAggregatesAndRespectsScope(t *testing.T) {
- db := newActivityDomainDB(t)
- svc := NewActivityDiscountService(db, fundSuperScope())
- ctx := context.Background()
- user := fundUserID(32)
- seedActivityFull(t, db, 412, model.ActivityTypeVoucher, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
- seedPool(t, db, user, 412, "100.00", "30.00") // 本校区:可用 70
- // 机构级池(campus_id=0):可用 10
- if err := db.Create(&model.CoursesActivityUserDiscount{
- ActivityID: 412, InstitutionID: 1, CampusID: 0, UserID: user,
- DiscountAmount: "10.00", DiscountAmountUsed: "0.00",
- }).Error; err != nil {
- t.Fatalf("seed 机构级池失败: %v", err)
- }
- // 已用尽的行不参与(face == used)
- seedPool(t, db, user, 412, "50.00", "50.00")
- // campusID=10:只命中本校区池 → 70
- res, err := svc.UsableAmount(ctx, UsableAmountInput{UserID: user, InstitutionID: 1, CampusID: 10})
- if err != nil {
- t.Fatalf("汇总可用额失败: %v", err)
- }
- if res.Usable.Cents() != 7000 {
- t.Fatalf("本校区可用额应 7000 分(等值过滤,不含机构级池),实得 %d", res.Usable.Cents())
- }
- // campusID=0:只命中机构级池 → 10
- res, err = svc.UsableAmount(ctx, UsableAmountInput{UserID: user, InstitutionID: 1, CampusID: 0})
- if err != nil {
- t.Fatalf("汇总可用额失败: %v", err)
- }
- if res.Usable.Cents() != 1000 {
- t.Fatalf("机构级池可用额应 1000 分,实得 %d", res.Usable.Cents())
- }
- // institutionID=0:平台视角,不加任何作用域条件 → 70+10=80
- res, err = svc.UsableAmount(ctx, UsableAmountInput{UserID: user})
- if err != nil {
- t.Fatalf("汇总可用额失败: %v", err)
- }
- if res.Usable.Cents() != 8000 {
- t.Fatalf("平台视角可用额应 8000 分,实得 %d", res.Usable.Cents())
- }
- }
- // seedDiscountLogRow 落一行 discount_log(负=消费/正=退回)。
- func seedDiscountLogRow(t *testing.T, db *gorm.DB, poolID int64, stuCourseID int64, amount string) {
- t.Helper()
- row := &model.CoursesActivityUserDiscountLog{
- ActivityUserDiscountID: poolID,
- CoursesStudentID: &stuCourseID,
- DiscountAmountUsed: amount,
- }
- if err := db.Create(row).Error; err != nil {
- t.Fatalf("seed discount_log 失败: %v", err)
- }
- }
- // 编译期断言:避免 money 包被"只用类型"时误删导入。
- var _ = money.FromCents
|