|
@@ -34,6 +34,7 @@ import (
|
|
|
"yuansiwei/unified-backend/internal/academic/money"
|
|
"yuansiwei/unified-backend/internal/academic/money"
|
|
|
infraerrors "yuansiwei/unified-backend/internal/infra/errors"
|
|
infraerrors "yuansiwei/unified-backend/internal/infra/errors"
|
|
|
appscope "yuansiwei/unified-backend/internal/infra/scope"
|
|
appscope "yuansiwei/unified-backend/internal/infra/scope"
|
|
|
|
|
+ "yuansiwei/unified-backend/internal/infra/tz"
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------- 测试基础设施
|
|
// ---------------------------------------------------------------- 测试基础设施
|
|
@@ -631,10 +632,28 @@ func TestRequestApproveTouchesFunds(t *testing.T) {
|
|
|
// 为什么两个都要下发(不能只留一个):只留一个就没法让前端**看见**不对称,
|
|
// 为什么两个都要下发(不能只留一个):只留一个就没法让前端**看见**不对称,
|
|
|
// 而契约 v1.2 那句"期末余额与当期发生额都按 `< to`"的错句,根因正是"看不见"。
|
|
// 而契约 v1.2 那句"期末余额与当期发生额都按 `< to`"的错句,根因正是"看不见"。
|
|
|
//
|
|
//
|
|
|
-// 本用例因此**刻意造一笔落在 `to` 当天的流水**(300.00),断言:
|
|
|
|
|
-// 1. 期末不含它(700.00)、`flowExclusiveEnd` 不含它(200.00);
|
|
|
|
|
-// 2. `flowInclusiveEnd` 含它(500.00);
|
|
|
|
|
-// 3. 二者之差 **恰好等于** 该笔金额(300.00)—— 也就是契约说的"落在 to 当天的流水"。
|
|
|
|
|
|
|
+// 本用例造 5 笔流水来同时守住"含 `to` 当天"的**两个端点**(台账 F31 / F30):
|
|
|
|
|
+//
|
|
|
|
|
+// 02-20 10:00 500.00 窗口前(期初基准)
|
|
|
|
|
+// 03-05 10:00 200.00 窗口内(唯一进明细的一笔)
|
|
|
|
|
+// 04-01 00:00 300.00 `to` 当天零点 —— 旧实现 `t <= to` 恰好能抓到这一刻
|
|
|
|
|
+// 04-01 15:00 150.00 `to` 当天下午 —— **只有**"含当天全天"才抓得到
|
|
|
|
|
+// 04-02 00:00 999.00 次日零点 —— 必须**排除**(守右端不得越到次日)
|
|
|
|
|
+//
|
|
|
|
|
+// 断言:
|
|
|
|
|
+// 1. 期末不含当天(700.00)、`flowExclusiveEnd` 不含当天(200.00);
|
|
|
|
|
+// 2. `flowInclusiveEnd` = 200 + 300 + 150 = 650.00;
|
|
|
|
|
+// 3. 二者之差 == 450.00(= 落在 `to` 当天的两笔之和),**不是** 300.00。
|
|
|
|
|
+//
|
|
|
|
|
+// ⚠ 为什么必须有 04-01 15:00 那笔:只造 00:00 一笔时,`t <= to` 与 `t < to+1d`
|
|
|
|
|
+// 会**同时**把它算进去 ⇒ 把 service 的 ② 回滚成 `!After(to)` 用例仍然是绿的
|
|
|
|
|
+// (零区分力)。加了下午那笔,两种实现才分得开:旧 500 / 新 650。
|
|
|
|
|
+//
|
|
|
|
|
+// ⚠ 种子时间必须用 `tz.Location()`(业务时区),**不能用 `time.UTC`**:
|
|
|
|
|
+// 测试库是 SQLite,时间列按**文本**比较,`time.UTC` 存成 `...T00:00:00Z`、
|
|
|
|
|
+// `+08:00` 存成 `...T00:00:00+08:00`,同一绝对时刻的字面量在 offset 段就分出了大小
|
|
|
|
|
+// ⇒ `create_time < to` 判错(实测过:同一时刻两种写法命中结果相反)。
|
|
|
|
|
+// 生产 MySQL 的 `datetime` 是无时区字面值、按 DSN `loc` 解释,不存在这个现象。
|
|
|
//
|
|
//
|
|
|
// 顺带守字段名:若有人把 service.BillSummary 的 json tag 改名,本用例立刻红
|
|
// 顺带守字段名:若有人把 service.BillSummary 的 json tag 改名,本用例立刻红
|
|
|
// (`flowExclusiveEnd` 解出来是零值 ⇒ 差值断言必失败)。
|
|
// (`flowExclusiveEnd` 解出来是零值 ⇒ 差值断言必失败)。
|
|
@@ -650,9 +669,15 @@ func TestBillEndpointIdentity(t *testing.T) {
|
|
|
t.Fatalf("seed 流水失败: %v", err)
|
|
t.Fatalf("seed 流水失败: %v", err)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
- seedLog("500.00", time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC)) // 期初
|
|
|
|
|
- seedLog("200.00", time.Date(2026, 3, 5, 10, 0, 0, 0, time.UTC)) // 窗口内
|
|
|
|
|
- seedLog("300.00", time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)) // **恰好落在 to 当天**
|
|
|
|
|
|
|
+ // 用业务时区构造(理由见上方 ⚠);`bizAt` 只省重复的 `tz.Location()`。
|
|
|
|
|
+ bizAt := func(month time.Month, day, hour int) time.Time {
|
|
|
|
|
+ return time.Date(2026, month, day, hour, 0, 0, 0, tz.Location())
|
|
|
|
|
+ }
|
|
|
|
|
+ seedLog("500.00", bizAt(2, 20, 10)) // 期初
|
|
|
|
|
+ seedLog("200.00", bizAt(3, 5, 10)) // 窗口内
|
|
|
|
|
+ seedLog("300.00", bizAt(4, 1, 0)) // `to` 当天零点
|
|
|
|
|
+ seedLog("150.00", bizAt(4, 1, 15)) // `to` 当天下午(只有"含当天全天"能抓到)
|
|
|
|
|
+ seedLog("999.00", bizAt(4, 2, 0)) // 次日零点(必须排除)
|
|
|
|
|
|
|
|
code, msg, data := doJSON(t, app, http.MethodGet,
|
|
code, msg, data := doJSON(t, app, http.MethodGet,
|
|
|
"/student/bill?userId="+user+"&from=2026-03-01&to=2026-04-01", nil)
|
|
"/student/bill?userId="+user+"&from=2026-03-01&to=2026-04-01", nil)
|
|
@@ -688,16 +713,18 @@ func TestBillEndpointIdentity(t *testing.T) {
|
|
|
if end.Sub(prev) != exclusive {
|
|
if end.Sub(prev) != exclusive {
|
|
|
t.Fatalf("恒等式破:期末−期初=%s,flowExclusiveEnd=%s", end.Sub(prev).String(), exclusive.String())
|
|
t.Fatalf("恒等式破:期末−期初=%s,flowExclusiveEnd=%s", end.Sub(prev).String(), exclusive.String())
|
|
|
}
|
|
}
|
|
|
- // 契约 v1.3:两口径差值 == 落在 to 当天的流水
|
|
|
|
|
- if got, want := inclusive.Sub(exclusive), int64Money(t, "300.00"); got != want {
|
|
|
|
|
- t.Fatalf("flowInclusiveEnd−flowExclusiveEnd = %s,期望 %s(= 落在 to 当天的流水)",
|
|
|
|
|
|
|
+ // 契约 v1.3:两口径差值 == 落在 `to` 当天的流水(300 + 150,**不含次日那笔 999**)
|
|
|
|
|
+ if got, want := inclusive.Sub(exclusive), int64Money(t, "450.00"); got != want {
|
|
|
|
|
+ t.Fatalf("flowInclusiveEnd−flowExclusiveEnd = %s,期望 %s(= 落在 to 当天的两笔之和)\n"+
|
|
|
|
|
+ "若实得 300.00 ⇒ 实现退化成 `t <= to`(只抓到当天零点那一刻,漏掉当天其余流水,台账 F31)\n"+
|
|
|
|
|
+ "若实得 1449.00 ⇒ 右端越到次日(把 04-02 那笔也算进来了)",
|
|
|
got.String(), want.String())
|
|
got.String(), want.String())
|
|
|
}
|
|
}
|
|
|
- if got, want := inclusive.String(), "500.00"; got != want {
|
|
|
|
|
- t.Fatalf("flowInclusiveEnd = %s,期望 %s(含 to 当天那笔)", got, want)
|
|
|
|
|
|
|
+ if got, want := inclusive.String(), "650.00"; got != want {
|
|
|
|
|
+ t.Fatalf("flowInclusiveEnd = %s,期望 %s(含 to 当天**全天**:200+300+150)", got, want)
|
|
|
}
|
|
}
|
|
|
- // v1.4 起明细按窗口过滤(右开):3 笔历史流水里只有 03-05 那笔在窗口内
|
|
|
|
|
- // (02-20 在窗口前、04-01 恰在 `to` 右端点)→ total=1、pages=1(ceil(1/10))。
|
|
|
|
|
|
|
+ // v1.4 起明细按窗口过滤(右开):5 笔历史流水里只有 03-05 那笔在窗口内
|
|
|
|
|
+ // (02-20 在窗口前;04-01 零点/下午与 04-02 零点都在 `to` 右端点之后或等于它)→ total=1、pages=1。
|
|
|
if bill.Total != 1 || bill.PageSize != 10 || bill.Pages != 1 || bill.Details == nil {
|
|
if bill.Total != 1 || bill.PageSize != 10 || bill.Pages != 1 || bill.Details == nil {
|
|
|
t.Fatalf("分页元信息异常:total=%d pageSize=%d pages=%d details=%v(明细应受窗口约束)",
|
|
t.Fatalf("分页元信息异常:total=%d pageSize=%d pages=%d details=%v(明细应受窗口约束)",
|
|
|
bill.Total, bill.PageSize, bill.Pages, bill.Details)
|
|
bill.Total, bill.PageSize, bill.Pages, bill.Details)
|
|
@@ -705,9 +732,9 @@ func TestBillEndpointIdentity(t *testing.T) {
|
|
|
if len(bill.Details) != 1 {
|
|
if len(bill.Details) != 1 {
|
|
|
t.Fatalf("窗口内明细应为 1 条,实得 %d 条", len(bill.Details))
|
|
t.Fatalf("窗口内明细应为 1 条,实得 %d 条", len(bill.Details))
|
|
|
}
|
|
}
|
|
|
- // Total 必须**不等于**历史总数(3 笔)——只改 PageLogs 不改 CountLogs 的实现会在这里露馅
|
|
|
|
|
- if bill.Total == 3 {
|
|
|
|
|
- t.Fatal("Total = 历史总数 3:说明 CountLogs 没跟 PageLogs 用同一个窗口条件(改一半)")
|
|
|
|
|
|
|
+ // Total 必须**不等于**历史总数(5 笔)——只改 PageLogs 不改 CountLogs 的实现会在这里露馅
|
|
|
|
|
+ if bill.Total == 5 {
|
|
|
|
|
+ t.Fatal("Total = 历史总数 5:说明 CountLogs 没跟 PageLogs 用同一个窗口条件(改一半)")
|
|
|
}
|
|
}
|
|
|
if bill.Summary.CampusIDs == nil {
|
|
if bill.Summary.CampusIDs == nil {
|
|
|
t.Fatal("campusIds 回显必须是 [](空时也不能是 null)")
|
|
t.Fatal("campusIds 回显必须是 [](空时也不能是 null)")
|