|
@@ -0,0 +1,453 @@
|
|
|
|
|
+// RecommendService 单测(AC-08):老带新双边结算 + 取消充值活动 / 欠费核销的转调。
|
|
|
|
|
+//
|
|
|
|
|
+// 断言纪律(对照 §5.1 第 4/5 条):
|
|
|
|
|
+// 1. **双边各自入账**(不是"总额对了就行"):被推荐人与推荐人的
|
|
|
|
|
+// `balance` / `total_recommended_amount` **各加一次**、**各一条 107**。
|
|
|
|
|
+// 只断言"存在 107 流水"或"某一边余额增加"的用例,在"只给一边入账"的实现下依然全绿。
|
|
|
|
|
+// 2. **`balance_after` 各取本 user 全局快照**(两个值不要求相等,多校区/多历史下本来不等)。
|
|
|
|
|
+// 3. **幂等回放等价(纪律 5)**:造"首次结算后账户又被别的写入改过"的场景,
|
|
|
|
|
+// 再次结算必须 `repeated=true` 且响应体余额 == **当刻库值**(回放旧值即红)。
|
|
|
|
|
+// 4. **转调不重复实现**:取消充值活动与欠费核销分别只经 RefundService / MaintenanceService,
|
|
|
|
|
+// 用例同时证明其资金效果与幂等回读。
|
|
|
|
|
+package service
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "context"
|
|
|
|
|
+ "testing"
|
|
|
|
|
+ "time"
|
|
|
|
|
+
|
|
|
|
|
+ "gorm.io/gorm"
|
|
|
|
|
+
|
|
|
|
|
+ "yuansiwei/unified-backend/internal/academic/model"
|
|
|
|
|
+ "yuansiwei/unified-backend/internal/academic/port"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+// ---------------------------------------------------------------- 辅助
|
|
|
|
|
+
|
|
|
|
|
+// seedStudentCourse 落一行报课记录(老带新的机构/校区兜底来源)。
|
|
|
|
|
+func seedStudentCourse(t *testing.T, db *gorm.DB, id int64, studentID string,
|
|
|
|
|
+ institutionID, campusID int64, remainderReceivable string) {
|
|
|
|
|
+ t.Helper()
|
|
|
|
|
+ confirmUser := "01HCONFIRM000000000000001"
|
|
|
|
|
+ row := &model.CoursesStudent{
|
|
|
|
|
+ ID: id, BizNo: "BIZ-" + time.Now().Format("150405.000000"), CoursesID: 1, ClassID: 1,
|
|
|
|
|
+ InstitutionID: institutionID, CampusID: campusID, StudentID: studentID,
|
|
|
|
|
+ RemainderReceivable: remainderReceivable, NetReceipts: "0.00",
|
|
|
|
|
+ ConfirmDiscountUserID: &confirmUser,
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := db.Create(row).Error; err != nil {
|
|
|
|
|
+ t.Fatalf("seed 报课记录(%d) 失败: %v", id, err)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// seedCoursesClass 落一行排班规则(欠费核销候选集的 INNER JOIN 对象)。
|
|
|
|
|
+func seedCoursesClass(t *testing.T, db *gorm.DB, coursesID, classID int64, startTime time.Time) {
|
|
|
|
|
+ t.Helper()
|
|
|
|
|
+ row := &model.CoursesClass{
|
|
|
|
|
+ CoursesID: coursesID, ClassID: classID, InstitutionID: 1, CampusID: 10,
|
|
|
|
|
+ StartTime: &startTime,
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := db.Create(row).Error; err != nil {
|
|
|
|
|
+ t.Fatalf("seed 排班规则失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// usedLogsOfActivity 取某活动的 used_log 行(按 id ASC)。
|
|
|
|
|
+func usedLogsOfActivity(t *testing.T, db *gorm.DB, activityID int64) []model.CoursesActivityUsedLog {
|
|
|
|
|
+ t.Helper()
|
|
|
|
|
+ var rows []model.CoursesActivityUsedLog
|
|
|
|
|
+ if err := db.Where("activity_id = ?", activityID).Order("id ASC").Find(&rows).Error; err != nil {
|
|
|
|
|
+ t.Fatalf("读 used_log 失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ return rows
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---------------------------------------------------------------- 老带新结算(SP18)
|
|
|
|
|
+
|
|
|
|
|
+// TestSettleRecommendDoubleSided 核心用例:双边各入一次账、各一条 107、used_log 负值、signed 结算态。
|
|
|
|
|
+func TestSettleRecommendDoubleSided(t *testing.T) {
|
|
|
|
|
+ db := newActivityDomainDB(t)
|
|
|
|
|
+ svc := NewRecommendService(db, fundSuperScope())
|
|
|
|
|
+ ctx := context.Background()
|
|
|
|
|
+ newUser := fundUserID(40)
|
|
|
|
|
+ recUser := fundUserID(41)
|
|
|
|
|
+
|
|
|
|
|
+ seedActivityFull(t, db, 500, model.ActivityTypeRecommend, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
|
|
|
|
|
+ rec := recUser
|
|
|
|
|
+ if err := db.Create(&model.CoursesActivitySigned{
|
|
|
|
|
+ ActivityID: 500, UserID: newUser, InstitutionID: 1, CampusID: 10,
|
|
|
|
|
+ Status: model.ActivitySignedSigned, ActivityMoney: "100.00", ActivityMoneyUsed: "0.00",
|
|
|
|
|
+ RecommendUserID: &rec,
|
|
|
|
|
+ }).Error; err != nil {
|
|
|
|
|
+ t.Fatalf("seed 老带新报名失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ res, err := svc.SettleRecommend(ctx, SettleRecommendInput{
|
|
|
|
|
+ UserID: newUser, OrderNo: "7001", Remark: "报名成功",
|
|
|
|
|
+ })
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("老带新结算失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if !res.Settled || res.Repeated {
|
|
|
|
|
+ t.Fatalf("首次结算应 Settled=true/Repeated=false,实得 %+v", res)
|
|
|
|
|
+ }
|
|
|
|
|
+ if res.RecommendUserID != recUser || res.Amount.Cents() != 10000 {
|
|
|
|
|
+ t.Fatalf("响应应回显推荐人与金额,实得 %+v", res)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ① 双边 **各自** 加 balance + total_recommended_amount(不是"合计对就行")
|
|
|
|
|
+ for _, uid := range []string{newUser, recUser} {
|
|
|
|
|
+ global := readGlobal(t, db, uid)
|
|
|
|
|
+ if cents(t, global.Balance) != 10000 {
|
|
|
|
|
+ t.Fatalf("user %s 全局余额应 10000 分,实得 %d", uid, cents(t, global.Balance))
|
|
|
|
|
+ }
|
|
|
|
|
+ if cents(t, global.TotalRecommendedAmount) != 10000 {
|
|
|
|
|
+ t.Fatalf("user %s 累计推荐额应 10000 分,实得 %d", uid, cents(t, global.TotalRecommendedAmount))
|
|
|
|
|
+ }
|
|
|
|
|
+ campus := readSchool(t, db, uid, 1, 10)
|
|
|
|
|
+ if cents(t, campus.Balance) != 10000 {
|
|
|
|
|
+ t.Fatalf("user %s 校区余额应 10000 分,实得 %d", uid, cents(t, campus.Balance))
|
|
|
|
|
+ }
|
|
|
|
|
+ if cents(t, campus.TotalRecommendedAmount) != 10000 {
|
|
|
|
|
+ t.Fatalf("user %s 校区累计推荐额应 10000 分,实得 %d", uid, cents(t, campus.TotalRecommendedAmount))
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ② **两条** 107,各属一个 user,金额都是 +m,logid = 报课单号
|
|
|
|
|
+ for _, uid := range []string{newUser, recUser} {
|
|
|
|
|
+ logs := logsByType(t, db, uid, model.ExpenseLogTypeRecommend)
|
|
|
|
|
+ if len(logs) != 1 {
|
|
|
|
|
+ t.Fatalf("user %s 应有 1 条 107,实得 %d 条", uid, len(logs))
|
|
|
|
|
+ }
|
|
|
|
|
+ if cents(t, logs[0].Money) != 10000 {
|
|
|
|
|
+ t.Fatalf("user %s 的 107 金额应 10000 分,实得 %d", uid, cents(t, logs[0].Money))
|
|
|
|
|
+ }
|
|
|
|
|
+ if logs[0].LogID == nil || *logs[0].LogID != "7001" {
|
|
|
|
|
+ t.Fatalf("user %s 的 107 logid 应为报课单号 7001,实得 %v", uid, logs[0].LogID)
|
|
|
|
|
+ }
|
|
|
|
|
+ if logs[0].BalanceAfter == nil || cents(t, *logs[0].BalanceAfter) != 10000 {
|
|
|
|
|
+ t.Fatalf("user %s 的 balance_after 应为本 user 全局快照 10000 分,实得 %v", uid, logs[0].BalanceAfter)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ③ used_log:**负值** -m + 推荐人 + 单号
|
|
|
|
|
+ used := usedLogsOfActivity(t, db, 500)
|
|
|
|
|
+ if len(used) != 1 {
|
|
|
|
|
+ t.Fatalf("used_log 应 1 条,实得 %d 条", len(used))
|
|
|
|
|
+ }
|
|
|
|
|
+ if cents(t, used[0].ActivityMoneyUsed) != -10000 {
|
|
|
|
|
+ t.Fatalf("used_log 金额应为 -10000 分,实得 %d", cents(t, used[0].ActivityMoneyUsed))
|
|
|
|
|
+ }
|
|
|
|
|
+ if used[0].RecommendUserID == nil || *used[0].RecommendUserID != recUser {
|
|
|
|
|
+ t.Fatalf("used_log 推荐人应为 %s,实得 %v", recUser, used[0].RecommendUserID)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ④ signed:结算态(status=2、activity_money_used=+m)
|
|
|
|
|
+ signed := readSignedRow(t, db, newUser, 500)
|
|
|
|
|
+ if signed.Status != model.ActivitySignedAttended || cents(t, signed.ActivityMoneyUsed) != 10000 {
|
|
|
|
|
+ t.Fatalf("报名行应结算为 status=2/used=10000,实得 status=%d used=%d",
|
|
|
|
|
+ signed.Status, cents(t, signed.ActivityMoneyUsed))
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestSettleRecommendReplayReadsLiveState 幂等回放等价(纪律 5):
|
|
|
|
|
+// 首次结算后**别的写入改过账户**,再次结算的响应体必须是当刻库值(回放旧值即红)。
|
|
|
|
|
+func TestSettleRecommendReplayReadsLiveState(t *testing.T) {
|
|
|
|
|
+ db := newActivityDomainDB(t)
|
|
|
|
|
+ svc := NewRecommendService(db, fundSuperScope())
|
|
|
|
|
+ ctx := context.Background()
|
|
|
|
|
+ newUser := fundUserID(42)
|
|
|
|
|
+ recUser := fundUserID(43)
|
|
|
|
|
+
|
|
|
|
|
+ seedActivityFull(t, db, 501, model.ActivityTypeRecommend, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
|
|
|
|
|
+ rec := recUser
|
|
|
|
|
+ if err := db.Create(&model.CoursesActivitySigned{
|
|
|
|
|
+ ActivityID: 501, UserID: newUser, InstitutionID: 1, CampusID: 10,
|
|
|
|
|
+ Status: model.ActivitySignedSigned, ActivityMoney: "100.00", ActivityMoneyUsed: "0.00",
|
|
|
|
|
+ RecommendUserID: &rec,
|
|
|
|
|
+ }).Error; err != nil {
|
|
|
|
|
+ t.Fatalf("seed 老带新报名失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ first, err := svc.SettleRecommend(ctx, SettleRecommendInput{UserID: newUser, OrderNo: "7002"})
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("首次结算失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 造"首次执行后账户被其它写入改过":被推荐人又充了 55 元
|
|
|
|
|
+ if err := db.Model(&model.UserExpense{}).Where("user_id = ?", newUser).
|
|
|
|
|
+ UpdateColumn("balance", "155.00").Error; err != nil {
|
|
|
|
|
+ t.Fatalf("模拟外部写入失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := db.Model(&model.UserExpenseSchool{}).Where("user_id = ?", newUser).
|
|
|
|
|
+ UpdateColumn("balance", "155.00").Error; err != nil {
|
|
|
|
|
+ t.Fatalf("模拟外部写入失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ second, err := svc.SettleRecommend(ctx, SettleRecommendInput{UserID: newUser, OrderNo: "7002"})
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("二次结算失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if !second.Repeated {
|
|
|
|
|
+ t.Fatalf("二次结算应 repeated=true(used_log 已存在)")
|
|
|
|
|
+ }
|
|
|
|
|
+ // ★ 回读断言:响应体每个金额字段 == 当刻库值
|
|
|
|
|
+ if second.UserBalance.Cents() != 15500 {
|
|
|
|
|
+ t.Fatalf("回放必须回读当刻余额 15500 分(旧值 10000 → 红),实得 %d", second.UserBalance.Cents())
|
|
|
|
|
+ }
|
|
|
|
|
+ if second.UserCampusBalance.Cents() != 15500 {
|
|
|
|
|
+ t.Fatalf("回放必须回读当刻校区余额 15500 分,实得 %d", second.UserCampusBalance.Cents())
|
|
|
|
|
+ }
|
|
|
|
|
+ if second.UserLogID != first.UserLogID || second.UsedLogID != first.UsedLogID {
|
|
|
|
|
+ t.Fatalf("回放应命中同一条 107/used_log(first=%d/%d second=%d/%d)",
|
|
|
|
|
+ first.UserLogID, first.UsedLogID, second.UserLogID, second.UsedLogID)
|
|
|
|
|
+ }
|
|
|
|
|
+ // 幂等不得再写一笔钱
|
|
|
|
|
+ if global := readGlobal(t, db, newUser); cents(t, global.Balance) != 15500 {
|
|
|
|
|
+ t.Fatalf("幂等命中不得再次入账,实得余额 %d", cents(t, global.Balance))
|
|
|
|
|
+ }
|
|
|
|
|
+ if logs := logsByType(t, db, newUser, model.ExpenseLogTypeRecommend); len(logs) != 1 {
|
|
|
|
|
+ t.Fatalf("幂等命中不得再写 107,实得 %d 条", len(logs))
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestSettleRecommendSkipsWithoutRecommender 无推荐人 → 零写入(dump 763340 LEAVE)。
|
|
|
|
|
+func TestSettleRecommendSkipsWithoutRecommender(t *testing.T) {
|
|
|
|
|
+ db := newActivityDomainDB(t)
|
|
|
|
|
+ svc := NewRecommendService(db, fundSuperScope())
|
|
|
|
|
+ ctx := context.Background()
|
|
|
|
|
+ user := fundUserID(44)
|
|
|
|
|
+
|
|
|
|
|
+ seedActivityFull(t, db, 502, model.ActivityTypeRecommend, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
|
|
|
|
|
+ if err := db.Create(&model.CoursesActivitySigned{
|
|
|
|
|
+ ActivityID: 502, UserID: user, InstitutionID: 1, CampusID: 10,
|
|
|
|
|
+ Status: model.ActivitySignedSigned, ActivityMoney: "100.00",
|
|
|
|
|
+ }).Error; err != nil {
|
|
|
|
|
+ t.Fatalf("seed 报名失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ res, err := svc.SettleRecommend(ctx, SettleRecommendInput{UserID: user, OrderNo: "7003"})
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("结算失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if res.Settled {
|
|
|
|
|
+ t.Fatalf("无推荐人时应零写入(Settled=false),实得 %+v", res)
|
|
|
|
|
+ }
|
|
|
|
|
+ if len(usedLogsOfActivity(t, db, 502)) != 0 {
|
|
|
|
|
+ t.Fatalf("无推荐人时不得写 used_log")
|
|
|
|
|
+ }
|
|
|
|
|
+ if len(logsByType(t, db, user, model.ExpenseLogTypeRecommend)) != 0 {
|
|
|
|
|
+ t.Fatalf("无推荐人时不得写 107")
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestSettleRecommendResolvesScopeFromStudentCourse 活动 schoolid/campusid=0 → 从报课记录回查(dump 763348)。
|
|
|
|
|
+func TestSettleRecommendResolvesScopeFromStudentCourse(t *testing.T) {
|
|
|
|
|
+ db := newActivityDomainDB(t)
|
|
|
|
|
+ svc := NewRecommendService(db, fundSuperScope())
|
|
|
|
|
+ ctx := context.Background()
|
|
|
|
|
+ newUser := fundUserID(45)
|
|
|
|
|
+ recUser := fundUserID(46)
|
|
|
|
|
+
|
|
|
|
|
+ // 活动为"全机构池"(campus_id=0)且 institution_id=0 → 必须回查报课记录
|
|
|
|
|
+ seedActivityFull(t, db, 503, model.ActivityTypeRecommend, "80.00", 0, 0, 0, model.ActivityStatusEnabled)
|
|
|
|
|
+ rec := recUser
|
|
|
|
|
+ if err := db.Create(&model.CoursesActivitySigned{
|
|
|
|
|
+ ActivityID: 503, UserID: newUser, InstitutionID: 0, CampusID: 0,
|
|
|
|
|
+ Status: model.ActivitySignedSigned, ActivityMoney: "80.00",
|
|
|
|
|
+ RecommendUserID: &rec,
|
|
|
|
|
+ }).Error; err != nil {
|
|
|
|
|
+ t.Fatalf("seed 报名失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ seedStudentCourse(t, db, 7100, newUser, 1, 10, "0.00")
|
|
|
|
|
+
|
|
|
|
|
+ res, err := svc.SettleRecommend(ctx, SettleRecommendInput{UserID: newUser, OrderNo: "7100"})
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("结算失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if !res.Settled || res.InstitutionID != 1 || res.CampusID != 10 {
|
|
|
|
|
+ t.Fatalf("应从报课记录回查机构/校区 (1/10),实得 settled=%v %d/%d",
|
|
|
|
|
+ res.Settled, res.InstitutionID, res.CampusID)
|
|
|
|
|
+ }
|
|
|
|
|
+ if got := cents(t, readGlobal(t, db, newUser).Balance); got != 8000 {
|
|
|
|
|
+ t.Fatalf("被推荐人余额应 8000 分,实得 %d", got)
|
|
|
|
|
+ }
|
|
|
|
|
+ if got := cents(t, readSchool(t, db, recUser, 1, 10).Balance); got != 8000 {
|
|
|
|
|
+ t.Fatalf("推荐人校区余额应 8000 分,实得 %d", got)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestSettleRecommendSkipsWhenScopeUnresolvable 活动与报课记录都给不出机构/校区 → 零写入(dump 763355)。
|
|
|
|
|
+func TestSettleRecommendSkipsWhenScopeUnresolvable(t *testing.T) {
|
|
|
|
|
+ db := newActivityDomainDB(t)
|
|
|
|
|
+ svc := NewRecommendService(db, fundSuperScope())
|
|
|
|
|
+ ctx := context.Background()
|
|
|
|
|
+ user := fundUserID(47)
|
|
|
|
|
+
|
|
|
|
|
+ seedActivityFull(t, db, 504, model.ActivityTypeRecommend, "80.00", 0, 0, 0, model.ActivityStatusEnabled)
|
|
|
|
|
+ rec := fundUserID(48)
|
|
|
|
|
+ if err := db.Create(&model.CoursesActivitySigned{
|
|
|
|
|
+ ActivityID: 504, UserID: user, InstitutionID: 0, CampusID: 0,
|
|
|
|
|
+ Status: model.ActivitySignedSigned, ActivityMoney: "80.00", RecommendUserID: &rec,
|
|
|
|
|
+ }).Error; err != nil {
|
|
|
|
|
+ t.Fatalf("seed 报名失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ res, err := svc.SettleRecommend(ctx, SettleRecommendInput{UserID: user, OrderNo: "999999"})
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("结算失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if res.Settled || len(usedLogsOfActivity(t, db, 504)) != 0 {
|
|
|
|
|
+ t.Fatalf("机构/校区无法解析时应零写入,实得 %+v", res)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestSettleRecommendSkipsOtherActivityTypes 非 type=4 的报名不进入老带新结算。
|
|
|
|
|
+func TestSettleRecommendSkipsOtherActivityTypes(t *testing.T) {
|
|
|
|
|
+ db := newActivityDomainDB(t)
|
|
|
|
|
+ svc := NewRecommendService(db, fundSuperScope())
|
|
|
|
|
+ ctx := context.Background()
|
|
|
|
|
+ user := fundUserID(49)
|
|
|
|
|
+
|
|
|
|
|
+ seedActivityFull(t, db, 505, model.ActivityTypeRecharge, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
|
|
|
|
|
+ rec := fundUserID(50)
|
|
|
|
|
+ if err := db.Create(&model.CoursesActivitySigned{
|
|
|
|
|
+ ActivityID: 505, UserID: user, InstitutionID: 1, CampusID: 10,
|
|
|
|
|
+ Status: model.ActivitySignedSigned, ActivityMoney: "100.00", RecommendUserID: &rec,
|
|
|
|
|
+ }).Error; err != nil {
|
|
|
|
|
+ t.Fatalf("seed 报名失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ res, err := svc.SettleRecommend(ctx, SettleRecommendInput{UserID: user, OrderNo: "7004"})
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("结算失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if res.Settled {
|
|
|
|
|
+ t.Fatalf("非老带新活动不得结算,实得 %+v", res)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---------------------------------------------------------------- 取消充值活动(SP8 转调)
|
|
|
|
|
+
|
|
|
|
|
+// TestRecommendCancelRechargeActivityDelegates 转调 RefundService:209 流水 -m、signed 归 0、余额减少。
|
|
|
|
|
+func TestRecommendCancelRechargeActivityDelegates(t *testing.T) {
|
|
|
|
|
+ db := newActivityDomainDB(t)
|
|
|
|
|
+ fund := NewFundService(db, fundSuperScope(), nil)
|
|
|
|
|
+ svc := NewRecommendService(db, fundSuperScope()).WithCampusReader(port.NewOrgCampusReader(db))
|
|
|
|
|
+ ctx := context.Background()
|
|
|
|
|
+ user := fundUserID(51)
|
|
|
|
|
+
|
|
|
|
|
+ seedCampus(t, db, 10, 1, true)
|
|
|
|
|
+ seedActivityFull(t, db, 506, model.ActivityTypeRecharge, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
|
|
|
|
|
+ seedSignedFull(t, db, user, 506, model.ActivitySignedSigned, "100.00", "0.00")
|
|
|
|
|
+ if _, err := fund.Recharge(ctx, RechargeInput{
|
|
|
|
|
+ UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "300.00"), RequestID: "REQ-SEED-1",
|
|
|
|
|
+ }); err != nil {
|
|
|
|
|
+ t.Fatalf("准备余额失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ res, err := svc.CancelRechargeActivity(ctx, CancelRechargeActivityInput{
|
|
|
|
|
+ UserID: user, ActivityID: 506, CampusID: 10,
|
|
|
|
|
+ Money: mustAmount(t, "100.00"), RequestID: "REQ-CANCEL-1",
|
|
|
|
|
+ })
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("取消充值活动失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if res.Repeated || res.InstitutionID != 1 || res.Balance.Cents() != 20000 {
|
|
|
|
|
+ t.Fatalf("首次取消应 non-repeated/机构 1/余额 20000,实得 %+v", res)
|
|
|
|
|
+ }
|
|
|
|
|
+ if logs := logsByType(t, db, user, model.ExpenseLogTypeCancelRechargeAct); len(logs) != 1 ||
|
|
|
|
|
+ cents(t, logs[0].Money) != -10000 {
|
|
|
|
|
+ t.Fatalf("应写 1 条 209 且金额 -10000 分,实得 %+v", logs)
|
|
|
|
|
+ }
|
|
|
|
|
+ if signed := readSignedRow(t, db, user, 506); signed.Status != model.ActivitySignedCancelled {
|
|
|
|
|
+ t.Fatalf("报名状态应归 0,实得 %d", signed.Status)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestRecommendCancelRechargeReplayReadsLiveState 转调路径的幂等回放也必须回读当刻库值(纪律 5)。
|
|
|
|
|
+func TestRecommendCancelRechargeReplayReadsLiveState(t *testing.T) {
|
|
|
|
|
+ db := newActivityDomainDB(t)
|
|
|
|
|
+ fund := NewFundService(db, fundSuperScope(), nil)
|
|
|
|
|
+ svc := NewRecommendService(db, fundSuperScope()).WithCampusReader(port.NewOrgCampusReader(db))
|
|
|
|
|
+ ctx := context.Background()
|
|
|
|
|
+ user := fundUserID(52)
|
|
|
|
|
+
|
|
|
|
|
+ seedCampus(t, db, 10, 1, true)
|
|
|
|
|
+ seedActivityFull(t, db, 507, model.ActivityTypeRecharge, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
|
|
|
|
|
+ seedSignedFull(t, db, user, 507, model.ActivitySignedSigned, "100.00", "0.00")
|
|
|
|
|
+ if _, err := fund.Recharge(ctx, RechargeInput{
|
|
|
|
|
+ UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "300.00"), RequestID: "REQ-SEED-2",
|
|
|
|
|
+ }); err != nil {
|
|
|
|
|
+ t.Fatalf("准备余额失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ first, err := svc.CancelRechargeActivity(ctx, CancelRechargeActivityInput{
|
|
|
|
|
+ UserID: user, ActivityID: 507, CampusID: 10,
|
|
|
|
|
+ Money: mustAmount(t, "100.00"), RequestID: "REQ-CANCEL-2",
|
|
|
|
|
+ })
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("首次取消失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if first.Balance.Cents() != 20000 {
|
|
|
|
|
+ t.Fatalf("首次取消后校区余额应 20000 分,实得 %d", first.Balance.Cents())
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 首次执行后被别的写入改过(再充 50)
|
|
|
|
|
+ if _, err := fund.Recharge(ctx, RechargeInput{
|
|
|
|
|
+ UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "50.00"), RequestID: "REQ-SEED-3",
|
|
|
|
|
+ }); err != nil {
|
|
|
|
|
+ t.Fatalf("模拟外部写入失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ second, err := svc.CancelRechargeActivity(ctx, CancelRechargeActivityInput{
|
|
|
|
|
+ UserID: user, ActivityID: 507, CampusID: 10,
|
|
|
|
|
+ Money: mustAmount(t, "100.00"), RequestID: "REQ-CANCEL-2",
|
|
|
|
|
+ })
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("二次取消失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if !second.Repeated {
|
|
|
|
|
+ t.Fatalf("二次取消应 repeated=true")
|
|
|
|
|
+ }
|
|
|
|
|
+ if second.Balance.Cents() != 25000 {
|
|
|
|
|
+ t.Fatalf("回放必须回读当刻校区余额 25000 分(旧值 20000 → 红),实得 %d", second.Balance.Cents())
|
|
|
|
|
+ }
|
|
|
|
|
+ if second.LogID != first.LogID {
|
|
|
|
|
+ t.Fatalf("回放应命中同一条 209(first=%d second=%d)", first.LogID, second.LogID)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---------------------------------------------------------------- 欠费核销(SP27 转调)
|
|
|
|
|
+
|
|
|
|
|
+// TestRecommendCounteractArrearsDelegates 转调 MaintenanceService:按剩余应收核销并写回两列。
|
|
|
|
|
+func TestRecommendCounteractArrearsDelegates(t *testing.T) {
|
|
|
|
|
+ db := newActivityDomainDB(t)
|
|
|
|
|
+ svc := NewRecommendService(db, fundSuperScope())
|
|
|
|
|
+ ctx := context.Background()
|
|
|
|
|
+ student := fundUserID(53)
|
|
|
|
|
+
|
|
|
|
|
+ startAt := time.Date(2026, 11, 1, 9, 0, 0, 0, time.UTC)
|
|
|
|
|
+ seedCoursesClass(t, db, 1, 1, startAt)
|
|
|
|
|
+ seedStudentCourse(t, db, 7200, student, 1, 10, "60.00")
|
|
|
|
|
+
|
|
|
|
|
+ res, err := svc.CounteractArrears(ctx, CounteractArrearsInput{
|
|
|
|
|
+ StudentID: student, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "100.00"),
|
|
|
|
|
+ })
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("欠费核销失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if res.Applied.Cents() != 6000 {
|
|
|
|
|
+ t.Fatalf("应核销 6000 分(截到剩余应收),实得 %d", res.Applied.Cents())
|
|
|
|
|
+ }
|
|
|
|
|
+ if res.Remaining.Cents() != 4000 {
|
|
|
|
|
+ t.Fatalf("未用完部分应 4000 分留在账户,实得 %d", res.Remaining.Cents())
|
|
|
|
|
+ }
|
|
|
|
|
+ var row model.CoursesStudent
|
|
|
|
|
+ if err := db.Where("id = ?", 7200).Take(&row).Error; err != nil {
|
|
|
|
|
+ t.Fatalf("读报课记录失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if cents(t, row.NetReceipts) != 6000 || cents(t, row.RemainderReceivable) != 0 {
|
|
|
|
|
+ t.Fatalf("核销后 net_receipts 应 6000/remainder 0,实得 %d/%d",
|
|
|
|
|
+ cents(t, row.NetReceipts), cents(t, row.RemainderReceivable))
|
|
|
|
|
+ }
|
|
|
|
|
+}
|