Pārlūkot izejas kodu

fix(academic): 端点58 teacherId 可空下发 null + 出口定点化(比例1位/金额2位/教龄3位)+ 纯函数单测

三处出口口径统一到「列的 scale」,并各自有字节级断言兜着:

1. `teacherId` 改 `*string`:`teacher_id` NULL 是「通用档」这一正常业务,下发 "" 会让前端
   `if (teacherId)` 把「写了老师但 ID 是空」的脏数据也当通用档 —— 数值全对、课酬算错。
   空串/空白一律收口为 null,出口只剩 `null` / 合法 ULID 两种取值。

2. 新增 service/decimal_format.go(零 float64、纯函数):
   percentage 恒 1 位(decimal(5,1))、金额 2 位、教龄 3 位。
   不定点化则 SQLite 回 "85"、MySQL 回 "85.0",前端编辑弹窗回填后 diff 误判为"用户改过";
   而单测只能退化成比数值 —— 恰恰丢掉「位数」这个量纲指纹("85.00" 说明比例被当成金额格式化)。

3. decimal_format_test.go 脱离数据库断言字节:`"85"→"85.0"`、`"100"→"100.00"`。
   四个变异均实测变红(见交付报告):比例改用金额位数 → 纯函数+HTTP 双层红;
   NULL 折叠为空串 → teacherId 用例红;去掉进位 → "85.05"→"85.0" 红;
   去掉右侧补零 → FormatYears("2.5")→"2.005"(数值放大 10 倍)红。
Q 14 stundas atpakaļ
vecāks
revīzija
066cb3942a

+ 10 - 3
internal/academic/handler/dto_payroll.go

@@ -190,12 +190,19 @@ type UpdatePayrollPlanReq struct {
 	Details *[]PayrollPlanDetailReq `json:"details"`
 }
 
-// payrollPlanDetailView 端点 58 响应的明细(契约 D 表 + D17 裁决字段)。
+// payrollPlanDetailView 端点 58 响应的明细(契约 D 表 + D17 裁决字段 + 出口定点化)。
+//
+// 出场口径(**三条都有"写错就红"的用例兜着**):
+//
+//	teacherId    → `null`(通用档,源列 NULL)或老师 ULID;**不下发空串**(空串无法区分"通用"与"脏数据")。
+//	percentage   → 恒 1 位小数("85.0")。`decimal(5,1)` 的位数就是它的量纲指纹:被按金额
+//	               格式化成 "85.00" 说明量纲被污染,数值恰好不变 ⇒ 只有位数断言抓得住。
+//	min/max/reward、teacherExpMin/Max → 金额 2 位、教龄 3 位(`money.Amount.String()` / `FormatYears`)。
 type payrollPlanDetailView struct {
 	// DetailID 明细 ID(源列 `t_course_payment_detail.id`)。
 	DetailID int64 `json:"detailId"`
-	// TeacherID 老师 ULID(可空 → 空串)。
-	TeacherID string `json:"teacherId"`
+	// TeacherID 老师 ULID(源列 `teacher_id`;NULL → `null`)。
+	TeacherID *string `json:"teacherId"`
 	// ClassifyID 课程分类 ID。
 	ClassifyID int64 `json:"classifyId"`
 	// ClassType 班级类型。

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

@@ -275,7 +275,7 @@ func toPayrollPlanView(plan *service.PayrollPlan) payrollPlanView {
 		d := &plan.Details[i]
 		details = append(details, payrollPlanDetailView{
 			DetailID:      d.DetailID,
-			TeacherID:     d.TeacherID,
+			TeacherID:     d.TeacherID, // 已是 *string:NULL 原样下发 null,不折叠成 ""
 			ClassifyID:    d.ClassifyID,
 			ClassType:     d.ClassType,
 			GradeID:       d.GradeID,
@@ -347,13 +347,19 @@ func (h *Handler) QuotePayroll(c *fiber.Ctx) error {
 	if err != nil {
 		return response.Fail(c, err) // 2001/1004/3112/5002
 	}
+	// 出口定点化:`percentage` 恒 1 位小数(`decimal(5,1)`)。不定点化的话 SQLite 回 "85"、
+	// MySQL 回 "85.0",前端 diff 会把它当成"用户改过",且单测断言随数据库方言摇摆。
+	percentage, perr := service.FormatPercentage(quote.Percentage)
+	if perr != nil {
+		return response.Fail(c, infraerrors.WithCause(infraerrors.ErrDBError, perr))
+	}
 	return response.OK(c, payrollQuoteView{
 		DetailID:       quote.DetailID,
 		SchemeID:       quote.SchemeID,
 		SchemeCampusID: quote.SchemeCampusID,
 		SchemeScope:    quote.SchemeScope,
 		RuleTier:       quote.RuleTier,
-		Percentage:     quote.Percentage,
+		Percentage:     percentage,
 		Min:            quote.Min.String(),
 		Max:            quote.Max.String(),
 		Reward:         quote.Reward.String(),

+ 127 - 21
internal/academic/handler/payroll_handler_test.go

@@ -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"])

+ 196 - 0
internal/academic/service/decimal_format.go

@@ -0,0 +1,196 @@
+// 十进制「定点格式化」纯函数(**不碰数据库、不碰时钟**)——AC-13d 追加。
+//
+// ---------------------------------------------------------------------------
+// 为什么必须有这一层(不是"多此一举的漂亮化")
+// ---------------------------------------------------------------------------
+//
+// 库里三类"小数字符串"列(`decimal(5,1)` 比例 / `decimal(10,3)` 教龄 / `decimal(12,2)` 金额)
+// 都以**字符串**进出 Go,于是同一个值在不同环境里长成不同样子:
+//
+//	MySQL 8        读 decimal(5,1) 的 85   → "85.0"
+//	SQLite(单测) 读 numeric 亲和列       → "85"     ← 尾部 0 被吃掉
+//	前端刚才写入    "85.00"(多打一位)    → "85.00"
+//
+// 三种形态**数值相等、字节不等**。后果不是"不好看",而是三条实打实的故障:
+//
+//  1. **前端 diff 误判**:编辑弹窗把接口回的值原样回填,第二次提交时"85" ≠ "85.0",
+//     被认为用户改过 ⇒ 生成一条脏变更(配置类页面最常见)。
+//  2. **单测在两个方言之间摇摆**:断言 `"85.0"` 在 SQLite 上必红、断言 `"85"` 在 MySQL 上必红 ⇒
+//     要么写 `mustDecimal` 把断言弱化成"数值对就行"(那就丢了"量纲/位数"这条防线),
+//     要么测试骨架随数据库变。**格式化到列声明的位数,两端都稳。**
+//  3. **金额与比例的"尺寸"混淆无人察觉**:`percentage` 如果被按金额格式化(1 位 vs 2 位),
+//     `"85.0" → "85.00"` 恰好数值不变 ⇒ 量纲被污染的 bug 静默通过。**位数本身就是量纲指纹。**
+//
+// 因此:**出口统一按"列声明的 scale"定点化**,入口只做区间校验。本文件是出口的唯一实现点。
+//
+// ---------------------------------------------------------------------------
+// 实现约束
+// ---------------------------------------------------------------------------
+//
+//   - **零 float64**:百分比/教龄虽不是资金,但"用浮点格式化再拼字符串"在 0.1 这种十进制
+//     不可表示值上会产生 `84.99999999999999` 之类的残渣;本文件全程字符串 + int64。
+//   - **纯函数**:无 IO、无全局状态、无时区依赖 ⇒ 可以脱离数据库做单测(`decimal_format_test.go`)。
+//   - **不静默吞错**:非法文本返回错误,由调用方映射(Handler 侧统一 5002「数据不一致」,
+//     因为走到格式化时数据已经出库,格式坏 = 库里写坏了,不是用户参数问题,**不能回 2002**)。
+package service
+
+import "strings"
+
+// 各类列的 scale(小数位数)——**与 DDL 声明一致,不要在这里"顺手统一"**。
+const (
+	// ScalePercentage `t_course_payment_detail.percentage` 是 `decimal(5,1)` ⇒ 1 位。
+	ScalePercentage = 1
+	// ScaleYears `t_course_payment_detail.teacher_exp_min/max` 是 `decimal(10,3)` ⇒ 3 位。
+	ScaleYears = 3
+	// ScaleYuan 金额列统一 `decimal(12,2)` ⇒ 2 位。
+	ScaleYuan = 2
+)
+
+// FormatPercentage 把百分数字符串定点化到 1 位小数("85" → "85.0","85.00" → "85.0")。
+//
+// 入参:text 库里的百分数("85" / "85.0" / "85.00")。
+// 出参:(定点化字符串, error);空串/非数字/超过 int64 量级 → ErrInvalidFormat。
+//
+// ⚠ **只格式化,不改量纲**:`"85"` 是 85%,不是 0.85。需要比率时由 `applyPercentage` 除 1000,
+// 不要在这里动手脚(否则"格式化了"会被误认为"归一化了")。
+func FormatPercentage(text string) (string, error) {
+	return FormatFixed(text, ScalePercentage)
+}
+
+// FormatYears 把教龄字符串定点化到 3 位小数("1" → "1.000","5" → "5.000")。
+//
+// 入参:text 库里的教龄区间端点("0" / "1.000" / "5")。
+// 出参:(定点化字符串, error);非法 → ErrInvalidFormat。
+//
+// 为什么教龄也要固定位数:它是**区间比较的展示值**(前端拿它判断"我这一档是不是到了"),
+// `0` 与 `0.000` 在 JS 里一个是 number 一个是 string,跨端比较会出现 `"0" > "0.000"` 之类的字符串比较坑。
+func FormatYears(text string) (string, error) {
+	return FormatFixed(text, ScaleYears)
+}
+
+// FormatYuan 把金额字符串定点化到 2 位小数("100" → "100.00","12.3" → "12.30")。
+//
+// 入参:text 库里的金额("0" / "100" / "300.00";空串按 0 处理,与 `parseAmount` 一致)。
+// 出参:(定点化字符串, error);非法 → ErrDBError(**不是 2002**:出库数据坏 = 库写坏了)。
+//
+// 内部走 `parseAmount(...).String()`:解析与输出都在定点整数域(`money` 包已禁 float64),
+// 这里不重复实现一遍舍入,**只有一处舍入规则**(第 3 位 ≥5 进位)。
+func FormatYuan(text string) (string, error) {
+	amt, err := parseAmount(text)
+	if err != nil {
+		return "", err
+	}
+	return amt.String(), nil
+}
+
+// FormatFixed 把十进制字符串四舍五入到固定小数位数(本包定点化的唯一实现)。
+//
+// 入参:
+//   - text  十进制字符串,允许前后空格与正负号("85" / "-0.01" / "1.23456");
+//   - scale 目标小数位(0~3,见 Scale* 常量)。
+//
+// 出参:(定点化字符串, error);以下情形返回 ErrInvalidFormat:
+// 空串、只有符号、整数位非数字(含 ".5")、小数位非数字(含 "12.")、整数位超过 12 位(量级异常)。
+//
+// 舍入:第 scale+1 位 ≥5 时进位(远离零方向),与 `money.NewFromYuanString` 同规则。
+// 负零归一:`"-0.0"` → `"0.0"`(不产生 `-0.0`,否则前端 `Number("-0.0")` 得到 -0,diff 又会误判)。
+func FormatFixed(text string, scale int) (string, error) {
+	s := strings.TrimSpace(text)
+	if s == "" {
+		return "", errString("decimal: 空字符串无法定点化")
+	}
+	neg := false
+	switch s[0] {
+	case '-':
+		neg = true
+		s = s[1:]
+	case '+':
+		s = s[1:]
+	}
+	if s == "" {
+		return "", errString("decimal: 只有符号")
+	}
+	intPart, fracPart, hasDot := strings.Cut(s, ".")
+	if intPart == "" || !allDigits(intPart) {
+		return "", errString("decimal: 整数位非法 " + text)
+	}
+	if hasDot && (fracPart == "" || !allDigits(fracPart)) {
+		return "", errString("decimal: 小数位非法 " + text) // "12." 与 "12.a" 都拒(库里的值不该长这样)
+	}
+	// 量级护栏:整数位超过 12 位即超出本项目任何 decimal 列(decimal(12,2) 上限 10 位整数),
+	// 继续算 int64 会静默溢出 ⇒ 明确拒绝(宁可 5002,也不要吐出错误金额)。
+	if len(intPart) > 12 {
+		return "", errString("decimal: 整数位过长(超过 12 位)" + text)
+	}
+	// 保留 scale 位,看下一位决定进位
+	kept := fracPart
+	roundUp := false
+	if len(kept) > scale {
+		roundUp = kept[scale] >= '5'
+		kept = kept[:scale]
+	}
+	for len(kept) < scale {
+		kept += "0"
+	}
+
+	var units int64 // 放大 10^scale 后的整数
+	for _, ch := range intPart {
+		units = units*10 + int64(ch-'0')
+	}
+	for i := 0; i < scale; i++ {
+		units *= 10
+	}
+	var frac int64
+	for i := 0; i < len(kept); i++ {
+		frac = frac*10 + int64(kept[i]-'0')
+	}
+	if roundUp {
+		frac++ // 99…9 + 1 由 int64 相加自然向整数位进位
+	}
+	units += frac
+	return renderFixed(units, scale, neg), nil
+}
+
+// renderFixed 把"放大 10^scale 后的整数"渲染成定点字符串(FormatFixed 的输出侧)。
+//
+// 入参:units 放大值(非负);scale 小数位;neg 是否为负。
+// 出参:定点字符串(`units=850, scale=1` → "85.0";`units=10000, scale=2` → "100.00")。
+func renderFixed(units int64, scale int, neg bool) string {
+	if scale <= 0 {
+		text := formatInt64(units)
+		if neg && units != 0 {
+			return "-" + text
+		}
+		return text
+	}
+	div := int64(1)
+	for i := 0; i < scale; i++ {
+		div *= 10
+	}
+	whole := units / div
+	frac := units % div
+	fracText := formatInt64(frac)
+	for len(fracText) < scale {
+		fracText = "0" + fracText
+	}
+	text := formatInt64(whole) + "." + fracText
+	if neg && units != 0 {
+		return "-" + text
+	}
+	return text
+}
+
+// formatInt64 把非负 int64 转十进制(避免为本文件拉 strconv 进热路径的语感,也便于统一)。
+func formatInt64(v int64) string {
+	if v == 0 {
+		return "0"
+	}
+	var buf [20]byte
+	i := len(buf)
+	for v > 0 {
+		i--
+		buf[i] = byte('0' + v%10)
+		v /= 10
+	}
+	return string(buf[i:])
+}

+ 171 - 0
internal/academic/service/decimal_format_test.go

@@ -0,0 +1,171 @@
+// 定点格式化纯函数单测(**不连库、不起 Fiber、不依赖时区**)。
+//
+// ---------------------------------------------------------------------------
+// 为什么这组用例必须存在(而不是"顺手写的字符串断言")
+// ---------------------------------------------------------------------------
+//
+// 之前的断言都活在 HTTP + SQLite 用例里,于是只能写 `mustDecimal`(比数值不比字节)——
+// 因为 SQLite 的 numeric 亲和性会把 "85.0" 存回 "85"。**这恰好把本层的价值测掉了**:
+// 字节层面的"位数"正是量纲指纹(百分比 1 位 / 金额 2 位 / 教龄 3 位),
+// 数值断言全绿也照样让「比例被按金额格式化」这个 bug 混过去。
+//
+// 所以这里**脱离数据库直接调纯函数**,断言字节:
+//
+//	输入 "85"   → 百分比出口 "85.0"    (不是 "85"、不是 "85.00")
+//	输入 "100"  → 金额出口   "100.00"  (不是 "100"、不是 "100.0")
+//
+// 三条变异(把 FormatPercentage 改成 ScaleYuan / 去掉补零 / 去掉进位)都会让本文件变红,
+// 而在 DB 用例里前两条**不会**红。这就是这组用例存在的唯一理由。
+
+package service
+
+import (
+	"strings"
+	"testing"
+)
+
+// TestFormatPercentageBytes 断言百分数出口恒为 1 位小数(字节级)。
+//
+// 关键用例:`"85"` → `"85.0"`。这条来自团队裁决原文("输入 "85" → 输出 "85.0""),
+// 也是 SQLite 环境下最容易漏掉的一条——库里读出来就是 "85",不补零就原样漏给前端。
+func TestFormatPercentageBytes(t *testing.T) {
+	cases := []struct {
+		name string
+		in   string
+		want string
+	}{
+		{"整数补零(SQLite 读出的形态)", "85", "85.0"},
+		{"已是一位小数", "85.0", "85.0"},
+		// ⚠ 这条专门钉"补零位置":一位小数必须补在**右侧**("1.5" → "1.5")。
+		// 若实现把补零补在左侧(frac 从 5 变成 "05"),会吐出 "1.05" —— 数值变了 10 倍,
+		// 而 "85"/"100" 这类整数输入**测不出来**(它们的 frac 本来就是 0)。
+		{"一位小数不能被左补零", "1.5", "1.5"},
+		{"两位小数中的有效位", "65.5", "65.5"},
+		{"两位小数截断(不能四舍五入成 85.1)", "85.04", "85.0"},
+		{"第三位 ≥5 进位", "85.05", "85.1"},
+		{"100 的百分数(不是金额 100.00)", "100", "100.0"},
+		{"零", "0", "0.0"},
+		{"带空格", "  85  ", "85.0"},
+		{"负零归一(不产出 -0.0)", "-0.0", "0.0"},
+	}
+	for _, tc := range cases {
+		got, err := FormatPercentage(tc.in)
+		if err != nil {
+			t.Fatalf("%s:FormatPercentage(%q) 意外报错 %v", tc.name, tc.in, err)
+		}
+		if got != tc.want {
+			t.Fatalf("%s:FormatPercentage(%q) = %q,期望 %q(百分比是 decimal(5,1),"+
+				"位数就是量纲指纹:\"85.00\" 说明被当成金额格式化了)", tc.name, tc.in, got, tc.want)
+		}
+	}
+}
+
+// TestFormatYuanBytes 断言金额出口恒为 2 位小数(字节级)。
+//
+// 关键用例:`"100"` → `"100.00"`(团队裁决原文),以及 `"12.3"` → `"12.30"`(一位补成两位)。
+func TestFormatYuanBytes(t *testing.T) {
+	cases := []struct {
+		name string
+		in   string
+		want string
+	}{
+		{"整数补两位(MySQL/SQLite 都可能读出这个形态)", "100", "100.00"},
+		{"一位小数补零", "12.3", "12.30"},
+		{"已是两位", "300.00", "300.00"},
+		{"第三位 ≥5 进位(与 money 同规则)", "0.005", "0.01"},
+		{"第三位 <5 舍去", "0.004", "0.00"},
+		{"空串按 0(与 parseAmount 一致)", "", "0.00"},
+		{"负数保留符号", "-1.5", "-1.50"},
+		{"负零归一", "-0.00", "0.00"},
+	}
+	for _, tc := range cases {
+		got, err := FormatYuan(tc.in)
+		if err != nil {
+			t.Fatalf("%s:FormatYuan(%q) 意外报错 %v", tc.name, tc.in, err)
+		}
+		if got != tc.want {
+			t.Fatalf("%s:FormatYuan(%q) = %q,期望 %q", tc.name, tc.in, got, tc.want)
+		}
+	}
+}
+
+// TestFormatYearsBytes 断言教龄区间端点恒为 3 位小数(`decimal(10,3)`)。
+func TestFormatYearsBytes(t *testing.T) {
+	cases := []struct {
+		name string
+		in   string
+		want string
+	}{
+		{"整数补三位", "1", "1.000"},
+		{"零", "0", "0.000"},
+		{"一位补三位", "2.5", "2.500"},
+		{"已三位", "5.000", "5.000"},
+		{"第四位进位", "1.2345", "1.235"},
+	}
+	for _, tc := range cases {
+		got, err := FormatYears(tc.in)
+		if err != nil {
+			t.Fatalf("%s:FormatYears(%q) 意外报错 %v", tc.name, tc.in, err)
+		}
+		if got != tc.want {
+			t.Fatalf("%s:FormatYears(%q) = %q,期望 %q", tc.name, tc.in, got, tc.want)
+		}
+	}
+}
+
+// TestFormatFixedRejectsBadInput 非法输入必须报错(**不静默返回 0**)。
+//
+// 为什么这条重要:格式化发生在**出库之后**,喂进来的都是库里的值。若非法值被静默当成 0,
+// 前端会看到一条"比例 0.0% / 教龄 0 年"的合法规则 ⇒ 课酬被算成 0 却查不出原因。
+// 报错(→ 5002 数据不一致)才能让人去查那条坏数据。
+func TestFormatFixedRejectsBadInput(t *testing.T) {
+	bad := []string{"", "   ", "-", "+", "abc", ".5", "12.", "1.2.3", "1a", "1 ", "0x10", "1e5"}
+	for _, in := range bad {
+		if in == "1 " { // 两侧空格是允许的(TrimSpace 后合法)
+			// "1 " 去掉尾部空格后是 "1",属于合法输入,跳过
+			if got, err := FormatPercentage(in); err != nil || got != "1.0" {
+				t.Fatalf("FormatPercentage(%q) = (%q,%v),期望 (\"1.0\",nil):两侧空白应被容忍", in, got, err)
+			}
+			continue
+		}
+		if got, err := FormatPercentage(in); err == nil {
+			t.Fatalf("FormatPercentage(%q) = %q 且无错,期望报错(非法值静默当 0 会让课酬静默为 0)", in, got)
+		}
+	}
+	// 整数位过长(超出任何 decimal 列的量级)必须拒绝,不能静默溢出
+	long := strings.Repeat("9", 13)
+	if got, err := FormatPercentage(long); err == nil {
+		t.Fatalf("FormatPercentage(%q) = %q 且无错,期望报错(整数位 >12 位视为数据坏)", long, got)
+	}
+}
+
+// TestFormatFixedNoFloat64Residue 定点化不得引入浮点残渣("用 float 拼字符串"的典型翻车形态)。
+//
+// 0.1 / 0.3 在二进制浮点里不可精确表示,若实现走 `fmt.Sprintf("%.1f", x)` 之类路径,
+// 会在某些值上吐出 84.99999999999999 / 0.30000000000000004 这类残渣。
+// 本用例断言"每个结果都只含数字、小数点与一个可选负号",且小数位数恰好等于 scale。
+func TestFormatFixedNoFloat64Residue(t *testing.T) {
+	inputs := []string{"85", "84.999999", "0.1", "0.3", "65.5", "99.95", "33.333333"}
+	for _, in := range inputs {
+		got, err := FormatPercentage(in)
+		if err != nil {
+			t.Fatalf("FormatPercentage(%q) 意外报错 %v", in, err)
+		}
+		dot := strings.IndexByte(got, '.')
+		if dot < 0 {
+			t.Fatalf("FormatPercentage(%q) = %q,缺少小数点(1 位小数必须显式补 0)", in, got)
+		}
+		if len(got)-dot-1 != ScalePercentage {
+			t.Fatalf("FormatPercentage(%q) = %q,小数位 %d 位,期望 %d 位",
+				in, got, len(got)-dot-1, ScalePercentage)
+		}
+		body := strings.TrimPrefix(got, "-")
+		for i := 0; i < len(body); i++ {
+			ch := body[i]
+			if ch == '.' || (ch >= '0' && ch <= '9') {
+				continue
+			}
+			t.Fatalf("FormatPercentage(%q) = %q,出现非数字字符 %q(浮点残渣/科学计数法)", in, got, ch)
+		}
+	}
+}

+ 62 - 14
internal/academic/service/payroll_plan_service.go

@@ -89,23 +89,32 @@ type PayrollPlanQuery struct {
 // 字段来源:
 //
 //	DetailID       ← t_course_payment_detail.id
-//	TeacherID      ← teacher_id(NULL → 空串)
+//	TeacherID      ← teacher_id(**可空**;NULL 原样下发 `null`,不是空串)
 //	ClassifyID     ← classify_id
 //	ClassType      ← class_type
 //	GradeID        ← grade_id
 //	SubjectID      ← subject_id
 //	TeacherCert    ← teacher_cert
-//	TeacherExpMin  ← teacher_exp_min(教龄区间下界,**不是单值 teacherExp**)
-//	TeacherExpMax  ← teacher_exp_max
-//	Percentage     ← percentage(**百分数**,"85.0" = 85%)
-//	Min            ← min_payment
-//	Max            ← max_payment
-//	Reward         ← reward
+//	TeacherExpMin  ← teacher_exp_min(教龄区间下界,**不是单值 teacherExp**;定点化到 3 位)
+//	TeacherExpMax  ← teacher_exp_max(定点化到 3 位)
+//	Percentage     ← percentage(**百分数**,"85.0" = 85%;定点化到 1 位)
+//	Min            ← min_payment(金额,2 位小数)
+//	Max            ← max_payment(金额,2 位小数)
+//	Reward         ← reward(金额,2 位小数)
 //	DisplayName    ← name(**明细的"计算方式名称"**;与方案名分开,见 D17)
 //	DetailActived  ← actived(**明细级**启用;方案的 actived 是另一层)
+//
+// ⚠ **`TeacherID` 用 `*string` 而不是 `string`**:`teacher_id` 可空,"通用规则"就是 NULL。
+// 下发 `""` 会让前端无法区分两件事:
+//
+//	① 这条规则**不限定老师**(NULL,通用档);
+//	② 这条规则限定了老师,但老师 ID 是空串(数据坏了,本不该存在)。
+//
+// 前者是正常业务,后者是脏数据,前端一个 `if (teacherId)` 就把两者混成一支——
+// 脏数据被当成"通用档"静默通过校验。`null` / `"ULID"` / 见不到第三种取值,才有排查价值。
 type PayrollPlanDetail struct {
 	DetailID      int64
-	TeacherID     string
+	TeacherID     *string
 	ClassifyID    int64
 	ClassType     int32
 	GradeID       int32
@@ -464,9 +473,17 @@ func buildPlanDetails(inputs []PayrollPlanDetailInput) ([]*model.CoursePaymentDe
 	return out, nil
 }
 
-// toPlanDetail 把明细行转成对外视图(金额定点解析;列写坏 → 5002)。
+// toPlanDetail 把明细行转成对外视图(**出口定点化 + 金额定点解析**;列写坏 → 5002)。
+//
+// 入参:d 明细行。出参:PayrollPlanDetail;5002(ErrDBError)。
 //
-// 入参:d 明细行。出参:PayrollPlanDetail;5002。
+// 三类字段的出口处理**必须都做**,且都不能省:
+//
+//	percentage / teacherExp  → `FormatFixed` 到列声明的位数(1 / 3 位)。
+//	                           不定点化 ⇒ SQLite 回 "85"、MySQL 回 "85.0",前端 diff 误判 + 单测随方言摇摆;
+//	                           **也正因为定点化,"比例被当成金额(2 位)"这种量纲污染会立刻显形**。
+//	金额(min/max/reward)  → `money.Amount.String()`,恒 2 位小数(定点整数域,无浮点)。
+//	teacherId              → NULL 保持 `nil`(下发 `null`),**不折叠成空串**(见字段注释)。
 func toPlanDetail(d *model.CoursePaymentDetail) (PayrollPlanDetail, error) {
 	minAmount, err := parseAmount(d.MinPayment)
 	if err != nil {
@@ -480,17 +497,29 @@ func toPlanDetail(d *model.CoursePaymentDetail) (PayrollPlanDetail, error) {
 	if err != nil {
 		return PayrollPlanDetail{}, err
 	}
+	percentage, err := FormatPercentage(d.Percentage)
+	if err != nil {
+		return PayrollPlanDetail{}, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	yearMin, err := FormatYears(d.TeacherExpMin)
+	if err != nil {
+		return PayrollPlanDetail{}, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	yearMax, err := FormatYears(d.TeacherExpMax)
+	if err != nil {
+		return PayrollPlanDetail{}, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
 	return PayrollPlanDetail{
 		DetailID:      d.ID,
-		TeacherID:     strValue(d.TeacherID),
+		TeacherID:     teacherIDOrNil(d.TeacherID),
 		ClassifyID:    d.ClassifyID,
 		ClassType:     d.ClassType,
 		GradeID:       d.GradeID,
 		SubjectID:     d.SubjectID,
 		TeacherCert:   d.TeacherCert,
-		TeacherExpMin: d.TeacherExpMin,
-		TeacherExpMax: d.TeacherExpMax,
-		Percentage:    d.Percentage,
+		TeacherExpMin: yearMin,
+		TeacherExpMax: yearMax,
+		Percentage:    percentage,
 		Min:           minAmount,
 		Max:           maxAmount,
 		Reward:        reward,
@@ -499,6 +528,25 @@ func toPlanDetail(d *model.CoursePaymentDetail) (PayrollPlanDetail, error) {
 	}, nil
 }
 
+// teacherIDOrNil 把可空老师 ID 归一为"有值才有指针"(NULL / 空白 → nil)。
+//
+// 入参:v 列值指针。出参:非空白字符串的指针;NULL 或纯空白 → nil。
+//
+// 为什么空白也归 nil:`teacher_id` 是 `varchar`,历史数据里存在 `''`(旧系统用空串代替 NULL)。
+// 若把 `''` 原样下发,前端 `if (teacherId)` 得到 false —— 行为上等同 NULL,但**字节不同**,
+// 又回到"同一语义两种表示"的老坑。这里统一收口为 `null`,让出口只有三种取值:
+// `null`(通用档)/ 合法 ULID / 见不到别的。
+func teacherIDOrNil(v *string) *string {
+	if v == nil {
+		return nil
+	}
+	trimmed := strings.TrimSpace(*v)
+	if trimmed == "" {
+		return nil
+	}
+	return &trimmed
+}
+
 // parsePercentage 解析百分数字符串为浮点(写入校验用;**不参与金额计算**)。
 //
 // 入参:text 百分数("85" / "85.0")。出参:(数值, error);非数字/负/多于 1 位小数 → 2002。