|
|
@@ -0,0 +1,545 @@
|
|
|
+// 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
|