Parcourir la source

fix(academic): 【P1】账单明细按查询窗口过滤(右开),与 FlowExclusiveEnd 同口径

问题:`PageLogs` / `CountLogs` 原本不带时间条件 ⇒ 同一响应里出现两个作用域:
`summary` 是 3 月的、明细却是**全部历史**、`total` 是历史总数。前端选"3 月账单"翻到的是全量流水,
属真 bug 级体验问题;也是 §5.1 纪律 4 要防的"各比各的"(汇总与明细各用一套口径)。

修法(dump 无明细列表 ⇒ 这块无 dump 约束,按自洽口径设计)
- `BillRepo.PageLogs(ctx, userID, from, to, page)`:加 `create_time >= from AND create_time < to`
  —— **右开**,与 `FlowExclusiveEnd` **完全同口径**(`from <= t < to`)。
- `BillRepo.CountLogs(ctx, userID, from, to)`:**逐字相同的 WHERE**(同快照、不二次查)。
  把"两处必须一致"写进方法注释:一旦只改一处,前端会出现"共 N 条但最后一页空",
  这类不一致两边都"看起来对",联调期极难定位。
- `BillService.GetBill`:调用点传 `in.From/in.To`,并改写"明细口径"文档段
  (原写"不按 from/to 过滤",现为"窗口内流水,右开"),写明与汇总的**唯一**口径差:
  落在 `t = to` 的流水进 `FlowInclusiveEnd`、不进明细——这正是 `flowInclusiveEnd` 存在的意义。
- from>to 时 `PageLogs` / `CountLogs` 一律 2001(而不是静默返回空集,掩盖调用方把时间窗写反)。

用例调整(口径变了,断言必须跟着变,否则是"假绿")
- 服务层:`TestGetBillSummaryInvariantAcrossPagination`(窗口内 2 笔:第 1 页满、第 2 页空);
  新增 `TestGetBillDetailsRespectWindow`(窗口前那笔、以及**恰好落在 to** 的那笔都不得出现在明细,
  同时断言 `inclusive − exclusive` == 落 to 的那笔);
  `TestGetBillDetailTitleAndMoney` 改为按窗口内倒序断言首末行。
- 仓储层:`TestBillRepoLoadLogsAndPageOrdering` 明确区分"`LoadLogs` 全量(供汇总)"
  与"`PageLogs`/`CountLogs` 窗口内(供明细)",并断言右端点那笔被排除;
  `TestBillRepoParamGuards` 补 `from>to` 的两条守卫。
- handler 层:`TestBillEndpointIdentity` 的 `total` 由历史 3 笔改为窗口内 1 笔(3 笔历史里
  02-20 在窗口前、04-01 恰在 `to` 右端点,只有 03-05 在窗口内)。

变异实测(改错即红、还原即绿)
- 把 `PageLogs` 的窗口条件去掉(回到 v1.3 的"全量明细")→
  `TestGetBillDetailsRespectWindow` **红**(实得 4 条,期望 2 条)、
  `TestGetBillSummaryInvariantAcrossPagination` **红**(第 2 页应为 0 条,实得 2 条)。

顺带:`payroll_service_test.go` 的 gofmt 对齐(上一条 P0 提交后的纯格式修正,无逻辑变更)。

go build / go vet / go test -short ./... 全绿(repository/service/handler 三包完整回归通过)。
Alex il y a 15 heures
Parent
commit
86172adcbf

+ 8 - 2
internal/academic/handler/fund_handler_test.go

@@ -684,8 +684,14 @@ func TestBillEndpointIdentity(t *testing.T) {
 	if got, want := inclusive.String(), "500.00"; got != want {
 		t.Fatalf("flowInclusiveEnd = %s,期望 %s(含 to 当天那笔)", got, want)
 	}
-	if bill.Total != 3 || bill.PageSize != 10 || bill.Pages != 1 || bill.List == nil {
-		t.Fatalf("分页元信息异常:total=%d pageSize=%d pages=%d list=%v", bill.Total, bill.PageSize, bill.Pages, bill.List)
+	// v1.4 起明细按窗口过滤(右开):3 笔历史流水里只有 03-05 那笔在窗口内
+	// (02-20 在窗口前、04-01 恰在 `to` 右端点)→ total=1、pages=1。
+	if bill.Total != 1 || bill.PageSize != 10 || bill.Pages != 1 || bill.List == nil {
+		t.Fatalf("分页元信息异常:total=%d pageSize=%d pages=%d list=%v(明细应受窗口约束)",
+			bill.Total, bill.PageSize, bill.Pages, bill.List)
+	}
+	if len(bill.List) != 1 {
+		t.Fatalf("窗口内明细应为 1 条,实得 %d 条", len(bill.List))
 	}
 	if bill.Summary.CampusIDs == nil {
 		t.Fatal("campusIds 回显必须是 [](空时也不能是 null)")

+ 29 - 8
internal/academic/repository/bill_repo.go

@@ -117,23 +117,35 @@ func (r *BillRepo) LoadLogs(ctx context.Context, userID string) ([]model.UserExp
 	return rows, nil
 }
 
-// PageLogs 分页取账单明细流水(**时间倒序**,与 dump 的 `SELECT * FROM t_user_expense_log` 列表视图同序)。
+// PageLogs 分页取账单明细流水(**时间倒序**;**按查询窗口过滤**)。
 //
 // 入参:
 //   - ctx 请求上下文;
 //   - userID 用户(ULID;空 → ErrBadParam);
+//   - from/to 查询窗口:**`from <= create_time < to`**(左闭右开,与 `FlowExclusiveEnd` **同口径**);
 //   - page 分页(PageQuery;内部 ClampPage:pageSize ∈ [1,200])。
 //
 // 出参:
 //   - []model.UserExpenseLog:`create_time DESC, id DESC`(同秒多行按 id 兜底,保证翻页不重不漏);
 //   - ErrBadParam / ErrDBError。
 //
-// 红线:**分页只影响明细**——期初/期末/各类当期发生额必须由 LoadLogs 的全量数据算出
+// 为什么要按窗口过滤(v1.4 口径更正,原实现不过滤):dump 没有明细列表,这块**无 dump 约束**。
+// 不过滤会造成"同一响应里两个作用域"——`summary` 是 3 月的、明细却是全部历史、`total` 是历史总数,
+// 前端选"3 月账单"看到的是全量流水,属真 bug 级体验问题。
+// 窗口口径**必须**与 `FlowExclusiveEnd`(右开)一致,否则"明细条数 × 金额"与"汇总发生额"
+// 会在 `t = to` 处对不上(那正是 dump 三种比较语义并存的坑,不能在明细上再造一个)。
+//
+// 红线:**分页只影响明细**——期初/期末/各类当期发生额仍由 LoadLogs 的全量数据算出
 // (BillService 的"期初/期末不随分页变化"用例正是拦这条)。
-func (r *BillRepo) PageLogs(ctx context.Context, userID string, page PageQuery) ([]model.UserExpenseLog, error) {
+func (r *BillRepo) PageLogs(
+	ctx context.Context, userID string, from, to time.Time, page PageQuery,
+) ([]model.UserExpenseLog, error) {
 	if userID == "" {
 		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: PageLogs 需要 user_id"))
 	}
+	if from.After(to) {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: PageLogs 的 from 不能晚于 to"))
+	}
 	db, err := r.db(ctx)
 	if err != nil {
 		return nil, err
@@ -142,7 +154,7 @@ func (r *BillRepo) PageLogs(ctx context.Context, userID string, page PageQuery)
 	rows := make([]model.UserExpenseLog, 0, pageSize)
 	qErr := db.Model(&model.UserExpenseLog{}).
 		Select("id", "type", "money", "create_time", "log_id").
-		Where("user_id = ?", userID).
+		Where("user_id = ? AND create_time >= ? AND create_time < ?", userID, from, to).
 		Order("create_time DESC").Order("id DESC").
 		Limit(pageSize).Offset(page.Offset()).
 		Find(&rows).Error
@@ -152,20 +164,29 @@ func (r *BillRepo) PageLogs(ctx context.Context, userID string, page PageQuery)
 	return rows, nil
 }
 
-// CountLogs 统计该 user 的流水条数(明细分页的 total)。
+// CountLogs 统计该 user 在查询窗口内的流水条数(明细分页的 total)。
 //
-// 入参:ctx 请求上下文;userID 用户(空 → ErrBadParam)。
+// 入参:ctx;userID 用户(空 → ErrBadParam);from/to 查询窗口(**与 PageLogs 完全相同的条件**)。
 // 出参:int64 条数(无数据为 0);ErrBadParam / ErrDBError。
-func (r *BillRepo) CountLogs(ctx context.Context, userID string) (int64, error) {
+//
+// 红线(同快照):本方法的 WHERE 必须与 `PageLogs` **逐字一致**(`from <= create_time < to`),
+// 否则 `total` 与 `list` 会指向两个不同的集合——前端分页器显示"共 N 条"但翻到最后一页却没有数据,
+// 这类不一致在联调期极难定位(两边都"看起来对")。改一处必须同时改另一处。
+func (r *BillRepo) CountLogs(ctx context.Context, userID string, from, to time.Time) (int64, error) {
 	if userID == "" {
 		return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: CountLogs 需要 user_id"))
 	}
+	if from.After(to) {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: CountLogs 的 from 不能晚于 to"))
+	}
 	db, err := r.db(ctx)
 	if err != nil {
 		return 0, err
 	}
 	var total int64
-	if cErr := db.Model(&model.UserExpenseLog{}).Where("user_id = ?", userID).Count(&total).Error; cErr != nil {
+	if cErr := db.Model(&model.UserExpenseLog{}).
+		Where("user_id = ? AND create_time >= ? AND create_time < ?", userID, from, to).
+		Count(&total).Error; cErr != nil {
 		return 0, infraerrors.WithCause(infraerrors.ErrDBError, cErr)
 	}
 	return total, nil

+ 33 - 16
internal/academic/repository/bill_repo_test.go

@@ -100,7 +100,11 @@ func TestBillRepoLoadRechargeRequestNosBetweenInclusive(t *testing.T) {
 	}
 }
 
-// TestBillRepoLoadLogsAndPageOrdering 全量取数与分页一致性(分页只切明细,不改全量)。
+// TestBillRepoLoadLogsAndPageOrdering 全量取数 vs 窗口内分页(两者作用域不同,必须都对)。
+//
+// v1.4 起:`LoadLogs` 仍是**全量**(dump 的 `temp_user_expense_log` 等价物,供汇总用),
+// 而 `PageLogs` / `CountLogs` **按窗口过滤**(`from <= create_time < to`,右开)。
+// 本用例同时钉住这两条,防止"为了明细过滤顺手把全量也裁了"(那会把期初算错)。
 func TestBillRepoLoadLogsAndPageOrdering(t *testing.T) {
 	db := newBillRepoDB(t)
 	ctx := context.Background()
@@ -126,32 +130,37 @@ func TestBillRepoLoadLogsAndPageOrdering(t *testing.T) {
 	if len(all) != 3 {
 		t.Fatalf("全量流水 = %d 条,期望 3(**不带时间/类型过滤**,同 dump 临时表)", len(all))
 	}
-	total, err := repo.CountLogs(ctx, userID)
+
+	from := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
+	to := time.Date(2026, 3, 6, 9, 0, 0, 0, time.UTC) // 右开:03-06 09:00 那笔**恰好落在 to**,应被排除
+	total, err := repo.CountLogs(ctx, userID, from, to)
 	if err != nil {
 		t.Fatalf("CountLogs 失败: %v", err)
 	}
-	if total != 3 {
-		t.Fatalf("CountLogs = %d,期望 3", total)
+	if total != 2 {
+		t.Fatalf("CountLogs = %d,期望 2(窗口内 2 笔;右端点那笔被 `create_time < to` 排除)", total)
 	}
 
-	page1, err := repo.PageLogs(ctx, userID, PageQuery{Page: 1, PageSize: 2})
+	page1, err := repo.PageLogs(ctx, userID, from, to, PageQuery{Page: 1, PageSize: 2})
 	if err != nil {
 		t.Fatalf("PageLogs(1) 失败: %v", err)
 	}
-	page2, err := repo.PageLogs(ctx, userID, PageQuery{Page: 2, PageSize: 2})
+	page2, err := repo.PageLogs(ctx, userID, from, to, PageQuery{Page: 2, PageSize: 2})
 	if err != nil {
 		t.Fatalf("PageLogs(2) 失败: %v", err)
 	}
-	if len(page1) != 2 || len(page2) != 1 {
-		t.Fatalf("分页条数 = %d/%d,期望 2/1", len(page1), len(page2))
+	if len(page1) != 2 || len(page2) != 0 {
+		t.Fatalf("分页条数 = %d/%d,期望 2/0(窗口内只有 2 笔;右端点那笔已被排除)", len(page1), len(page2))
 	}
-	// 倒序:第 1 页首行是最新的 03-06 那笔
-	if !sameRepoAmount(t, page1[0].Money, "-5.00") {
-		t.Fatalf("第 1 页首行 money = %s,期望 -5.00(时间倒序)", page1[0].Money)
+	// 同一秒两行按 id DESC:第 1 页首行应是 id 更大的 107,次行是 100
+	if page1[0].Type != model.ExpenseLogTypeRecommend || page1[1].Type != model.ExpenseLogTypeRecharge {
+		t.Fatalf("同一秒内排序不稳定:page1=%+v", page1)
 	}
-	// 同一秒两行按 id DESC:第 1 页次行应是 id 更大的 107
-	if page1[1].Type != model.ExpenseLogTypeRecommend || page2[0].Type != model.ExpenseLogTypeRecharge {
-		t.Fatalf("同一秒内排序不稳定:page1=%+v page2=%+v", page1[1], page2[0])
+	// 窗口外(恰好等于 to)那笔不得出现在明细
+	for _, row := range page1 {
+		if !row.CreateTime.Before(to) {
+			t.Fatalf("明细含右端点流水:%+v", row)
+		}
 	}
 }
 
@@ -201,12 +210,20 @@ func TestBillRepoParamGuards(t *testing.T) {
 	ctx := context.Background()
 	repo := NewBillRepo(db)
 
+	from := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
+	to := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
+
 	_, err := repo.LoadLogs(ctx, "")
 	wantRepoErr(t, err, infraerrors.CodeBadParam, "LoadLogs 空 user")
-	_, err = repo.PageLogs(ctx, "", PageQuery{})
+	_, err = repo.PageLogs(ctx, "", from, to, PageQuery{})
 	wantRepoErr(t, err, infraerrors.CodeBadParam, "PageLogs 空 user")
-	_, err = repo.CountLogs(ctx, "")
+	_, err = repo.CountLogs(ctx, "", from, to)
 	wantRepoErr(t, err, infraerrors.CodeBadParam, "CountLogs 空 user")
+	// from>to:分页与计数都必须拒绝(否则会静默返回空集,掩盖调用方的时间窗写反)
+	_, err = repo.PageLogs(ctx, "01HUSER000000000000000930", to, from, PageQuery{})
+	wantRepoErr(t, err, infraerrors.CodeBadParam, "PageLogs from 晚于 to")
+	_, err = repo.CountLogs(ctx, "01HUSER000000000000000930", to, from)
+	wantRepoErr(t, err, infraerrors.CodeBadParam, "CountLogs from 晚于 to")
 	_, err = repo.LoadActivitySigned(ctx, "")
 	wantRepoErr(t, err, infraerrors.CodeBadParam, "LoadActivitySigned 空 user")
 	_, err = repo.LoadActivityUsedLogs(ctx, "")

+ 14 - 7
internal/academic/service/bill_service.go

@@ -216,11 +216,16 @@ type BillResult struct {
 //   - ErrBadParam(2001) 缺参/时间窗非法;ErrUnscoped(1004) 越权(注入了 MembershipChecker 时);
 //     ErrDBError(5002) 数据库异常或金额字面量解析失败。
 //
-// **明细口径**:`Details` 是该账户的**全量流水**(`create_time DESC, id DESC` 分页),
-// **不按 from/to 过滤** —— dump 的 `get_bill` 只返回 9 个汇总列、没有明细列表;设计 §3.4 #25
-// 要求"Go 侧支持分页明细 + 汇总对象两段返回",明细即"账户流水账",配合汇总看当期发生额。
-// 若前端要"只看窗口内流水",按 `Details[].CreateTime` 自行过滤或另开参数(本期不加,
-// 免得"明细被窗口裁掉、汇总却是全量"这种两处口径不一的错觉)。
+// **明细口径(v1.4 口径更正)**:`Details` 是**查询窗口内的流水**,条件与 `FlowExclusiveEnd`
+// **完全一致**(`from <= create_time < to`,左闭右开),按 `create_time DESC, id DESC` 分页;
+// `Total` 用**逐字相同**的 WHERE 统计(同快照,不二次查)。
+//
+// 为什么改(原实现"明细不过滤、汇总按窗口"是错的):那会让同一响应里出现两个作用域——
+// `summary` 是 3 月的、明细却是全部历史、`total` 是历史总数。前端选"3 月账单"翻到的是全量流水,
+// 属真 bug 级体验问题;而"明细与汇总口径不一致"恰恰是 §5.1 纪律 4 要防的那类"各比各的"。
+// dump 没有明细列表 ⇒ 这块**无 dump 约束**,可按上述自洽口径自由设计。
+// 明细与汇总的口径差**只有一处**且是显式的:落在 `t = to` 的流水进 `FlowInclusiveEnd`(含右端),
+// 但不进明细(右开)——前端口径表已写明,`flowInclusiveEnd` 就是给它看的。
 //
 // 只读:本方法**不开事务**(dump 的 4 张 MEMORY 临时表是一次性快照,等价于"同一读视图";
 // 目标态在单连接上顺序执行 4 次查询,落库并发写入造成的瞬间不一致由"流水只增不改"兜底)。
@@ -273,11 +278,13 @@ func (s *BillService) GetBill(ctx context.Context, in GetBillInput) (*BillResult
 
 	page := repository.PageQuery{Page: in.Page, PageSize: in.PageSize}
 	pageNo, pageSize := page.Normalize()
-	total, err := s.repo.CountLogs(ctx, in.UserID)
+	// 明细与汇总同窗口:`from <= create_time < to`(右开,与 FlowExclusiveEnd 同口径);
+	// total 与 list 用**逐字相同**的 WHERE(同快照,不二次查)。
+	total, err := s.repo.CountLogs(ctx, in.UserID, in.From, in.To)
 	if err != nil {
 		return nil, err
 	}
-	rows, err := s.repo.PageLogs(ctx, in.UserID, page)
+	rows, err := s.repo.PageLogs(ctx, in.UserID, in.From, in.To, page)
 	if err != nil {
 		return nil, err
 	}

+ 58 - 12
internal/academic/service/bill_service_test.go

@@ -252,7 +252,11 @@ func TestGetBillTimeBoundaryDiffersByExactlyOneFlow(t *testing.T) {
 	}
 }
 
-// TestGetBillSummaryInvariantAcrossPagination 横切断言 ③:翻页只影响明细。
+// TestGetBillSummaryInvariantAcrossPagination 横切断言 ③:翻页只影响明细(汇总逐字段不变)。
+//
+// v1.4 起明细**按窗口过滤**(`from <= create_time < to`),故 fixture 窗口内只有 2 笔
+// (03-05 消费、03-31 推荐);pageSize=2 时第 1 页满、第 2 页空——这正好同时验证
+// "翻页不改变汇总"与"total 与 list 同口径"(total=2 而非历史 4 笔)。
 func TestGetBillSummaryInvariantAcrossPagination(t *testing.T) {
 	_, svc, userID, from, to := billFixture(t)
 	ctx := context.Background()
@@ -268,18 +272,53 @@ func TestGetBillSummaryInvariantAcrossPagination(t *testing.T) {
 	if !reflect.DeepEqual(page1.Summary, page2.Summary) {
 		t.Fatalf("汇总随分页变化(必须不随分页变化):\n第1页 %+v\n第2页 %+v", page1.Summary, page2.Summary)
 	}
-	if page1.Total != 4 || page2.Total != 4 {
-		t.Fatalf("明细总数 = %d/%d,期望 4/4(账户区间对账覆盖全部流水)", page1.Total, page2.Total)
+	if page1.Total != 2 || page2.Total != 2 {
+		t.Fatalf("明细总数 = %d/%d,期望 2/2(**窗口内**流水,不是历史 4 笔)", page1.Total, page2.Total)
+	}
+	if len(page1.Details) != 2 || len(page2.Details) != 0 {
+		t.Fatalf("分页失效:len=%d/%d,期望第 1 页满 2 条、第 2 页 0 条", len(page1.Details), len(page2.Details))
+	}
+	// 明细按 `create_time DESC, id DESC`:窗口内倒序应是 03-31(107) → 03-05(101)
+	if page1.Details[0].Type != model.ExpenseLogTypeRecommend || page1.Details[0].Money.String() != "30.00" {
+		t.Fatalf("第 1 页首行应为 03-31 的 107/+30.00,实得 %+v", page1.Details[0])
+	}
+	if page1.Details[1].Type != model.ExpenseLogTypeCourseCampus {
+		t.Fatalf("第 1 页次行应为 03-05 的 101,实得 %+v", page1.Details[1])
+	}
+}
+
+// TestGetBillDetailsRespectWindow 明细必须受查询窗口约束(v1.4):右开,且与 FlowExclusiveEnd 同口径。
+//
+// 三笔流水分别在窗口前(02-20)、窗口内(03-05/03-31)、窗口右端点上(04-01 = `to`):
+//   - 窗口前那笔**不得**出现在明细(否则前端选"3 月"会看到 2 月流水);
+//   - 落在 `t = to` 的那笔**不得**出现在明细(右开),但它**计入** `FlowInclusiveEnd`
+//     —— 这就是 summary 与明细之间唯一的、且被显式下发的口径差。
+func TestGetBillDetailsRespectWindow(t *testing.T) {
+	_, svc, userID, from, to := billFixture(t)
+	res, err := svc.GetBill(context.Background(), GetBillInput{
+		UserID: userID, From: from, To: to, Page: 1, PageSize: 10,
+	})
+	if err != nil {
+		t.Fatalf("GetBill 失败: %v", err)
 	}
-	if len(page1.Details) != 2 || len(page2.Details) != 2 {
-		t.Fatalf("分页大小失效:len=%d/%d,期望 2/2", len(page1.Details), len(page2.Details))
+	if len(res.Details) != 2 {
+		t.Fatalf("窗口内明细应为 2 条,实得 %d 条:%+v", len(res.Details), res.Details)
 	}
-	// 明细按 `create_time DESC, id DESC`:第1页应是 04-01 与 03-31
-	if page1.Details[0].Type != model.ExpenseLogTypeRecharge || page1.Details[0].Money.String() != "200.00" {
-		t.Fatalf("第 1 页首行应为 04-01 的 100/+200.00,实得 %+v", page1.Details[0])
+	for _, d := range res.Details {
+		if d.CreateTime.Before(from) {
+			t.Fatalf("明细含**窗口前**流水(%s < from %s)", d.CreateTime, from)
+		}
+		if !d.CreateTime.Before(to) {
+			t.Fatalf("明细含**窗口右端点**流水(%s >= to %s):右开窗口必须排除", d.CreateTime, to)
+		}
+		if d.LogID == "REQ-A" || d.LogID == "REQ-B" {
+			t.Fatalf("明细含窗口外工单流水 %s(REQ-A 在窗口前、REQ-B 恰在 to)", d.LogID)
+		}
 	}
-	if page1.Details[1].Type != model.ExpenseLogTypeRecommend {
-		t.Fatalf("第 1 页次行应为 03-31 的 107,实得 %+v", page1.Details[1])
+	// 口径差显式可见:右端点那笔进 inclusive、不进明细
+	diff := res.Summary.FlowInclusiveEnd.Sub(res.Summary.FlowExclusiveEnd)
+	if got, want := diff.String(), "200.00"; got != want {
+		t.Fatalf("inclusive−exclusive = %s,期望 %s(= 落 to 的那笔,它不进明细)", got, want)
 	}
 }
 
@@ -380,6 +419,9 @@ func TestGetBillDetailTitleAndMoney(t *testing.T) {
 		model.ExpenseLogTypeCourseCampus: "课程消费(校区)",
 		model.ExpenseLogTypeRecommend:    "老带新推荐",
 	}
+	if len(res.Details) == 0 {
+		t.Fatal("窗口内应有明细,实得 0 条")
+	}
 	for _, d := range res.Details {
 		if want, ok := wantTitles[d.Type]; ok && d.TypeTitle != want {
 			t.Errorf("type=%d 标题 = %q,期望 %q", d.Type, d.TypeTitle, want)
@@ -389,8 +431,12 @@ func TestGetBillDetailTitleAndMoney(t *testing.T) {
 			t.Errorf("明细金额格式异常: %s(应两位小数)", d.Money.String())
 		}
 	}
-	if res.Details[0].LogID != "REQ-B" {
-		t.Errorf("首行 logId = %q,期望 REQ-B(倒序)", res.Details[0].LogID)
+	// 窗口内倒序首行 = 03-31 的 107(logId 为空),末行 = 03-05 的 101
+	if res.Details[0].Type != model.ExpenseLogTypeRecommend {
+		t.Errorf("首行 type = %d,期望 107(窗口内倒序首行)", res.Details[0].Type)
+	}
+	if res.Details[len(res.Details)-1].Type != model.ExpenseLogTypeCourseCampus {
+		t.Errorf("末行 type = %d,期望 101", res.Details[len(res.Details)-1].Type)
 	}
 }
 

+ 1 - 1
internal/academic/service/payroll_service_test.go

@@ -673,7 +673,7 @@ func TestFormatTeacherExpThreeDecimals(t *testing.T) {
 		{1, "1.000"},
 		{1.2349, "1.235"},
 		{0, "0.000"},
-		{-3, "0.000"},   // 负值钳 0(dump 763609)
+		{-3, "0.000"}, // 负值钳 0(dump 763609)
 		{0.999, "0.999"},
 		{1000, "999.000"}, // 超 decimal(6,3) 上限 → 夹到 999.999 以内
 		{math.NaN(), "0.000"},