|
|
@@ -113,17 +113,31 @@ func TestPayrollPlanCreateAndList(t *testing.T) {
|
|
|
if name, _ := first["displayName"].(string); name != "集体班-无证档" {
|
|
|
t.Fatalf("明细 displayName = %v,期望 集体班-无证档(被 schemeName 覆盖就是同名字段)", first["displayName"])
|
|
|
}
|
|
|
- // ⚠ 教龄区间同样不比较字面量(SQLite 无 DECIMAL:"1.000" 读回是 "1")——比数值,
|
|
|
- // 真正要钉住的是"两个界分别下发且各自正确",不是填充位数。
|
|
|
- if got := mustDecimal(t, first["teacherExpMin"]); got != 1.0 {
|
|
|
- t.Fatalf("teacherExpMin = %v,期望 1.0(区间下界必须单独下发)", first["teacherExpMin"])
|
|
|
- }
|
|
|
- if got := mustDecimal(t, first["teacherExpMax"]); got != 5.0 {
|
|
|
- t.Fatalf("teacherExpMax = %v,期望 5.0(区间上界必须单独下发,否则前端改档会写坏)", first["teacherExpMax"])
|
|
|
- }
|
|
|
- if got := mustDecimal(t, first["percentage"]); got != 85 {
|
|
|
- t.Fatalf("percentage = %v(数值 %v),期望 85(百分数);0.85 说明被当比率、8500 说明被 ×100",
|
|
|
- first["percentage"], got)
|
|
|
+ // ⚠ 这三条从"比数值"升级为"比字节"(出口定点化之后才可能这么断言)。
|
|
|
+ // 定点化前 SQLite 的 numeric 亲和性把 "1.000" 读回 "1"、"85.0" 读回 "85",
|
|
|
+ // 只能写 mustDecimal 比数值 —— 而"位数"恰好是量纲指纹(百分比 1 位 / 教龄 3 位 / 金额 2 位),
|
|
|
+ // 比数值等于把这条防线让出去。现在出口恒按列 scale 定点化,两种方言字节一致,才钉得住。
|
|
|
+ if got, _ := first["teacherExpMin"].(string); got != "1.000" {
|
|
|
+ t.Fatalf("teacherExpMin = %v,期望 \"1.000\"(区间下界单独下发,且定点化到 decimal(10,3))",
|
|
|
+ first["teacherExpMin"])
|
|
|
+ }
|
|
|
+ if got, _ := first["teacherExpMax"].(string); got != "5.000" {
|
|
|
+ t.Fatalf("teacherExpMax = %v,期望 \"5.000\"(区间上界必须单独下发,否则前端改档会写坏)",
|
|
|
+ first["teacherExpMax"])
|
|
|
+ }
|
|
|
+ if got, _ := first["percentage"].(string); got != "85.0" {
|
|
|
+ t.Fatalf("percentage = %v,期望 \"85.0\"(**百分数且 1 位小数**);"+
|
|
|
+ "\"0.85\" 说明被当比率、\"8500\" 说明被 ×100、\"85.00\" 说明被当金额格式化", first["percentage"])
|
|
|
+ }
|
|
|
+ if got, _ := first["min"].(string); got != "0.00" {
|
|
|
+ t.Fatalf("min = %v,期望 \"0.00\"(金额 2 位小数;\"0\" 说明金额没走定点出口)", first["min"])
|
|
|
+ }
|
|
|
+ // `teacherId` 未填 ⇒ 通用档 ⇒ 必须是 JSON `null`,**不能是 ""**。
|
|
|
+ // 空串无法区分"不限定老师"(正常)与"限定了老师但 ID 为空"(脏数据)。
|
|
|
+ if v, ok := first["teacherId"]; !ok {
|
|
|
+ t.Fatalf("响应里没有 teacherId 字段(契约要求存在且可空)")
|
|
|
+ } else if v != nil {
|
|
|
+ t.Fatalf("teacherId = %#v,期望 null(未填老师 = 通用档);\"\" 会让前端把脏数据当通用档", v)
|
|
|
}
|
|
|
second, _ := details[1].(map[string]any)
|
|
|
if v, _ := second["detailActived"].(bool); v {
|
|
|
@@ -150,7 +164,97 @@ func TestPayrollPlanCreateAndList(t *testing.T) {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-// TestPayrollPlanUpdateReplacesDetails 端点 58 PUT:`:id` = 方案 id,**明细整体替换**(先删后插)。
|
|
|
+// TestPayrollPlanTeacherIDNullVersusULID 端点 58:`teacherId` 为 NULL 时下发 `null`,**不是 `""`**。
|
|
|
+//
|
|
|
+// 变红条件(每条都对应一个真实可犯的错):
|
|
|
+//
|
|
|
+// 字段声明成 `string` 且用 `strValue()` 兜底 → NULL 变成 "",本条断言红;
|
|
|
+// 写入侧把空 teacherId 写成 `""` 而不是 NULL → 库里 teacher_id = '',本条"库值必须 IS NULL"红;
|
|
|
+// `teacherIDOrNil` 不 trim → 历史脏值 " " 被当成有值下发,本条红(见下用例)。
|
|
|
+//
|
|
|
+// 为什么值得单独立一条用例:`teacherId` 是"通用档"与"老师专属档"的唯一区分依据。
|
|
|
+// 若 NULL 被折叠成 "",前端 `if (teacherId)` 两支都走"通用档",**专属档的规则会被当成通用档生效**——
|
|
|
+// 数值全对、类型全对,只有课酬算错,且错得很隐蔽(多算/少算都合法)。
|
|
|
+func TestPayrollPlanTeacherIDNullVersusULID(t *testing.T) {
|
|
|
+ db := newCourseTestDB(t)
|
|
|
+ app := newPayrollTestApp(t, db)
|
|
|
+ const teacherULID = "01HTEACHER0000000000000042"
|
|
|
+ body := map[string]any{
|
|
|
+ "schemeName": "专属档 vs 通用档", "campusId": 10, "actived": true,
|
|
|
+ "details": []map[string]any{
|
|
|
+ // 第 1 条:老师专属档
|
|
|
+ {"classType": 1000, "gradeId": -1, "subjectId": -1, "percentage": "90.0",
|
|
|
+ "teacherId": teacherULID, "displayName": "专属档"},
|
|
|
+ // 第 2 条:通用档(不传 teacherId)
|
|
|
+ {"classType": 1000, "gradeId": -1, "subjectId": -1, "percentage": "70.0",
|
|
|
+ "displayName": "通用档"},
|
|
|
+ // 第 3 条:传空串(等价于不传,**不许落成 ""**)
|
|
|
+ {"classType": 1000, "gradeId": -1, "subjectId": -1, "percentage": "60.0",
|
|
|
+ "teacherId": "", "displayName": "空串档"},
|
|
|
+ },
|
|
|
+ }
|
|
|
+ code, msg, data := doJSON(t, app, fiber.MethodPost, PathPayrollPlans, body, "X-Test-Token", "t")
|
|
|
+ if code != 200 {
|
|
|
+ t.Fatalf("新建课酬方案 = %d(%s),期望 200", code, msg)
|
|
|
+ }
|
|
|
+ planID, _ := jsonMap(t, data)["id"].(float64)
|
|
|
+ assertTeacherID := func(when string, details []any) {
|
|
|
+ t.Helper()
|
|
|
+ if len(details) != 3 {
|
|
|
+ t.Fatalf("%s:details = %d 条,期望 3", when, len(details))
|
|
|
+ }
|
|
|
+ d0, _ := details[0].(map[string]any)
|
|
|
+ if got, _ := d0["teacherId"].(string); got != teacherULID {
|
|
|
+ t.Fatalf("%s:专属档 teacherId = %#v,期望 %q(有值必须原样下发)", when, d0["teacherId"], teacherULID)
|
|
|
+ }
|
|
|
+ for i := 1; i < 3; i++ {
|
|
|
+ di, _ := details[i].(map[string]any)
|
|
|
+ v, ok := di["teacherId"]
|
|
|
+ if !ok {
|
|
|
+ t.Fatalf("%s:第 %d 条没有 teacherId 字段(可空字段也必须存在)", when, i+1)
|
|
|
+ }
|
|
|
+ if v != nil {
|
|
|
+ t.Fatalf("%s:第 %d 条 teacherId = %#v,期望 null(不填/空串都是通用档);"+
|
|
|
+ "空串会让前端把「写了老师但 ID 是空」的脏数据也当通用档", when, i+1, v)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ assertTeacherID("POST 响应", jsonMap(t, data)["details"].([]any))
|
|
|
+
|
|
|
+ // 列表回读:同一口径(GET 与 POST 不能两套规格)
|
|
|
+ code, msg, data = doJSON(t, app, fiber.MethodGet,
|
|
|
+ PathPayrollPlans+"?institutionId=1&pageSize=10", nil, "X-Test-Token", "t")
|
|
|
+ if code != 200 {
|
|
|
+ t.Fatalf("方案列表 = %d(%s),期望 200", code, msg)
|
|
|
+ }
|
|
|
+ items := pageList(t, data)
|
|
|
+ if len(items) != 1 {
|
|
|
+ t.Fatalf("方案条数 = %d,期望 1", len(items))
|
|
|
+ }
|
|
|
+ row, _ := items[0].(map[string]any)
|
|
|
+ assertTeacherID("GET 列表", row["details"].([]any))
|
|
|
+
|
|
|
+ // 库值:空 teacherId 必须落 NULL(不是 "")——否则"通用档"在 SQL 里会变成
|
|
|
+ // `teacher_id = ''`,而 SQL 的三值逻辑下 `teacher_id = ''` 永远匹配不到"通用档"这句查询。
|
|
|
+ var rows []model.CoursePaymentDetail
|
|
|
+ if err := db.Where("course_payment_id = ?", int64(planID)).Order("id ASC").Find(&rows).Error; err != nil {
|
|
|
+ t.Fatalf("回读明细失败: %v", err)
|
|
|
+ }
|
|
|
+ if len(rows) != 3 {
|
|
|
+ t.Fatalf("库中明细 %d 条,期望 3", len(rows))
|
|
|
+ }
|
|
|
+ if rows[0].TeacherID == nil || *rows[0].TeacherID != teacherULID {
|
|
|
+ t.Fatalf("库中第 1 条 teacher_id = %v,期望 %q", rows[0].TeacherID, teacherULID)
|
|
|
+ }
|
|
|
+ for i := 1; i < 3; i++ {
|
|
|
+ if rows[i].TeacherID != nil {
|
|
|
+ t.Fatalf("库中第 %d 条 teacher_id = %q,期望 NULL(空串会被存成 '',通用档就永远匹配不到)",
|
|
|
+ i+1, *rows[i].TeacherID)
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
//
|
|
|
// 变红条件:
|
|
|
// - 把 `:id` 当明细 id(按明细删改)→ 方案字段改不动 / 明细条数不对;
|
|
|
@@ -276,16 +380,18 @@ func TestPayrollQuoteReturnsRuleTraceableFields(t *testing.T) {
|
|
|
t.Fatalf("ruleTier 为空:溯源字段不得裁掉(契约 §4.6 v0.1.29)")
|
|
|
}
|
|
|
// 量纲红线:percentage 是**百分数字符串**,不是金额(不能被 money 格式化)。
|
|
|
- // ⚠ 不比较字面量:SQLite 无 DECIMAL 类型,"85.0" 读回来是 "85";生产 MySQL 是 "85.0"。
|
|
|
- // 断言"数值等于 85 且**不是 0.85 / 8500**"——这才是"被当 0~1 比率或被 ×100"的真实失败条件。
|
|
|
+ // 出口已定点化到 1 位小数 ⇒ 这里可以直接比字节,**并且这条断言正是"量纲污染"的探测器**:
|
|
|
+ // 数值 85 在 "85.0"(百分比)/ "85.00"(金额)/ "0.85"(比率)三种写法下"数值断言"各有不同,
|
|
|
+ // 但 "85.00" 与 "85.0" 的差别只有位数 —— 只有字节断言抓得住"比例被按金额格式化"。
|
|
|
pctText, _ := payload["percentage"].(string)
|
|
|
- pctValue, pctErr := strconv.ParseFloat(strings.TrimSpace(pctText), 64)
|
|
|
- if pctErr != nil {
|
|
|
- t.Fatalf("percentage = %q 不是合法百分数字符串: %v", pctText, pctErr)
|
|
|
- }
|
|
|
- if pctValue != 85 {
|
|
|
- t.Fatalf("percentage = %q(数值 %v),期望 85(百分数);0.85 说明被当比率、8500 说明被 ×100",
|
|
|
- pctText, pctValue)
|
|
|
+ if pctText != "85.0" {
|
|
|
+ t.Fatalf("percentage = %q,期望 \"85.0\"(百分数 + 1 位小数);"+
|
|
|
+ "\"0.85\" 说明被当比率、\"8500\" 说明被 ×100、\"85.00\" 说明量纲被当成了金额",
|
|
|
+ pctText)
|
|
|
+ }
|
|
|
+ // 数值断言保留一条:即使字节被人为改坏,也要能看出"是不是 85 这个数"
|
|
|
+ if pctValue, pctErr := strconv.ParseFloat(strings.TrimSpace(pctText), 64); pctErr != nil || pctValue != 85 {
|
|
|
+ t.Fatalf("percentage = %q(解析 %v,错 %v),数值必须是 85", pctText, pctValue, pctErr)
|
|
|
}
|
|
|
if got, _ := payload["min"].(string); got != "0.00" {
|
|
|
t.Fatalf("min = %v,期望 \"0.00\"", payload["min"])
|