Преглед изворни кода

fix(academic): 【P0】教龄列精度保留 3 位(decimal(6,2)→(6,3)),修正区间命中集合

问题(架构师-2 AC-10 审计):源列 `t_course_payment_detail.teacher_exp_min/max` 是 `float(10,3)`,
目标态原写 `DECIMAL(6,2)` —— 收掉第 3 位会让 `0.999` 存成 `1.00`,于是
`[0.000, 0.999]` 与 `[1.000, 1.999]` 在教龄 = 1.0 处**并列命中**,
再靠 `ORDER BY grade_id DESC, subject_id DESC, id ASC` 取到**低档**(id=1)⇒ 课酬按错档位算。
这不是容差问题(§5.1「容差恒为 0」):它**改变了命中集合**,D7 双跑必红而代码"看起来没错"。

修法:两列改为 `DECIMAL(6,3)`(保留 3 位、不平移)。教龄不是金额、没有"分"的概念,
精度必须与源列一致才能保证区间语义不变;`DECIMAL(6,3)` 上限 999.999 年,够用。

四处同步(§2.6 哈希门禁要求同一变更内一致)
1. 文档 DDL `unified/02-data-model/academic-model-ddl.sql`:两列 → `DECIMAL(6,3)`,
   DEFAULT 0.000,注释写明"源 float(10,3) 保留 3 位,收敛会改变区间命中集合";
   收敛说明段的例外清单同步为 `decimal(6,3)(教龄非金额,保留 3 位)`。
   ⚠ 该文件**在代码仓之外**,无法进本提交,已改于磁盘并记录新指纹(见下)。
2. `internal/academic/model/testdata/academic-model-ddl.sql`:逐字节覆盖为文档副本(doc==fixture 实测 True)。
3. `internal/academic/model/testdata/academic-model-ddl.sha256`:重算
   `sha256-lf-v1 = 25797302b71fd74e4af8632ebb27a629555bc20f5ef296a16631c25880f4fbdf`
   (doc 与 fixture 各自归一后摘要**相同**,三方一致)。
4. `internal/academic/model/models_payroll.go`:`type:decimal(6,2)` → `(6,3)`、
   `default:0.00` → `0.000`,列注释写明收敛后果(`grep -rn "decimal(6,2)" internal/` 已清零)。

连带修正(同源,避免"列 3 位、入参 2 位"的错位)
- `service/payroll_service.go` `formatTeacherExp`:格式化改 **3 位**(×1000 + `%d.%03d`),
  上限夹到 999(`decimal(6,3)` 上限 999.999)。理由:教龄参与 `BETWEEN exp_min AND exp_max`
  的**数值**比较,2 位格式化会把 1.234 送成 1.23,边界可判性直接丢失。
- `repository/payroll_repo.go` 注释里的 `decimal(6,2)` 口径同步为 `(6,3)`。

新增回归用例(三条,各自守一个失败形态)
- `TestModelsColumnByColumn`(既有):tag ↔ DDL 逐列比对 —— **本次修前它就是红的**
  ("teacher_exp_min 类型 = DECIMAL(6,2),DDL = DECIMAL(6,3)"),改 tag 后转绿。
- `TestPayrollRepoTeacherExpThreeDecimalScale`(新):`[0.000,0.999]` / `[1.000,1.999]` 两档,
  断言教龄 1.000 只命中高档、0.999/1.999 上界闭区间、0.000 下界闭区间、2.000 落空。
- `TestQuotePayrollTeacherExpBoundaryOneYearHitsUpperBand` + `TestFormatTeacherExpThreeDecimals`(新):
  服务层语义(1.0 → detail 91)+ 格式化位数("1.000"/"1.235"/钳 0/超界夹 999)。

变异实测(改错即红、还原即绿)
- 变异 A:model tag 改回 `(6,2)` → `TestModelsColumnByColumn` **红**(实得 DECIMAL(6,2) ≠ DDL (6,3))。
- 变异 B:`teacher_exp_max >= ?` 改成 `>` → `TestPayrollRepoTeacherExpThreeDecimalScale` **红**(1.000 命中 nil)。
- 变异 C:`formatTeacherExp` 回退 2 位 → `TestFormatTeacherExpThreeDecimals` **红**(1.0 → "1.00")。

诚实边界(写进用例注释):本仓单测栈是 sqlite,NUMERIC 亲和性**不强制 decimal scale**,
所以"(6,2) 把 0.999 舍成 1.00"这一形态在 sqlite 下无法复现 —— 该形态由
`TestModelsColumnByColumn`(tag ↔ DDL)承接,语义用例守的是比较运算符与边界归属,两者互补。

go build / go vet / go test -short ./... 全绿。
Alex пре 15 часа
родитељ
комит
a10707bd2d

+ 39 - 37
internal/academic/model/models_payroll.go

@@ -7,6 +7,8 @@
 //  1. 方案两级回退:campus_id 命中 → 回退 campus_id=0(机构级);
 //  2. 规则三级回退:个人(teacher_id=uid)→ 持证通用(teacher_id 空 + teacher_cert=1)→ 无证通用(teacher_cert=0);
 //  3. grade_id / subject_id 支持 −1 通配;教龄区间用 BETWEEN(teacher_exp_min..max)。
+//     教龄列保留 **decimal(6,3)**(源 float(10,3)):教龄不是金额、没有"分"的概念;
+//     收敛到 2 位会把 0.999 变成 1.00,使相邻两档**并列命中** ⇒ 课酬按错档位算(D7 双跑必红)。
 //
 // 无可用方案 → 3112(apperr.ErrPayrollPlanMissing)。
 //
@@ -36,7 +38,7 @@ type CoursePayment struct {
 	EffectDate    *time.Time `gorm:"index:idx_inst_campus_actived_effect,priority:4;column:effect_date" json:"effectDate,omitempty"`                // 生效日期(datetime(3);取 effect_date<=当前 最新一条)|枚举:无|写入:创建/编辑|FK:无|红线:同机构校区多方案按本列 DESC 取首条
 	Actived       bool       `gorm:"index:idx_inst_campus_actived_effect,priority:3;column:actived;not null;default:0" json:"actived"`              // 是否生效(bit(1)→tinyint(1)/bool)|枚举:false/true|写入:启用/停用|FK:无|红线:仅 actived=true 参与匹配
 	CreateUserID  *string    `gorm:"column:create_user_id;size:26" json:"createUserId,omitempty"`                                                   // 创建人(ULID;原 create_userid)|枚举:无|写入:创建|FK:→ t_user(逻辑)|红线:禁止旧 int id
-	CreateTime    time.Time  `gorm:"column:create_time;not null;default:CURRENT_TIMESTAMP" json:"createTime"`                                                                 // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:勿手改
+	CreateTime    time.Time  `gorm:"column:create_time;not null;default:CURRENT_TIMESTAMP" json:"createTime"`                                       // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:勿手改
 	Memo          *string    `gorm:"column:memo;type:longtext" json:"memo,omitempty"`                                                               // 备注|枚举:无|写入:创建/编辑|FK:无|红线:脱敏
 }
 
@@ -48,23 +50,23 @@ func (CoursePayment) TableName() string { return "t_course_payment" }
 // grade_id / subject_id 支持 −1 通配(匹配逻辑保留旧口径,设计 §3.4 #26);
 // percentage 为 1 位小数的百分比数字部分(如 65.5 表示 65.5%)。
 type CoursePaymentDetail struct {
-	ID              int64   `gorm:"column:id;primaryKey;autoIncrement" json:"id"`                                                                      // 明细 ID(D3 自增)|枚举:无|写入:INSERT 自动|FK:无|红线:试算按 id ASC 取首条命中(回退优先级靠查询顺序)
-	CoursePaymentID int64   `gorm:"index:idx_payment;column:course_payment_id;not null" json:"coursePaymentId"`                                        // 课酬方案 ID(原 course_paymentid)|枚举:无|写入:创建规则时|FK:→ t_course_payment.id|红线:方案停用即整组失效
-	GradeID         int32   `gorm:"column:grade_id;not null;default:-1" json:"gradeId"`                                                                // 年级 ID(-1 所有/0 混合)|枚举:−1 通配 + 年级字典|写入:创建规则|FK:→ 教学域年级|红线:−1 通配逻辑不得写成 `= -1` 强制条件
-	SubjectID       int32   `gorm:"column:subject_id;not null;default:-1" json:"subjectId"`                                                            // 科目 ID(-1 所有/0 混合)|枚举:−1 通配 + 科目字典|写入:创建规则|FK:→ 教学域科目|红线:同上
-	ClassType       int32   `gorm:"index:idx_teacher_classify,priority:2;column:class_type;not null;default:0" json:"classType"`                       // 班级类型(对应 t_courses_classify.class_type)|枚举:班型编码|写入:创建规则|FK:弱关联 t_courses_classify.class_type|红线:匹配维度
-	ClassifyID      int64   `gorm:"index:idx_teacher_classify,priority:3;column:classify_id;not null;default:0" json:"classifyId"`                     // 课程分类 ID(原 classifyid;个人配置维度)|枚举:无|写入:创建规则|FK:→ t_courses_classify.id|红线:0=不限定分类
-	TeacherID       *string `gorm:"index:idx_teacher_classify,priority:1;column:teacher_id;size:26" json:"teacherId,omitempty"`                        // 老师(ULID;NULL/空=通用规则)|枚举:无|写入:个人规则时|FK:→ t_user(逻辑)|红线:三级回退的第一级判据(个人优先)
-	Name            *string `gorm:"column:name;size:300" json:"name,omitempty"`                                                                        // 计算方式名称|枚举:无|写入:创建规则|FK:无|红线:展示转义
-	Percentage      string  `gorm:"column:percentage;type:decimal(5,1);not null;default:0.0" json:"percentage"`                                        // 课酬比例(百分比数字部分,1 位小数;旧 float(10,1))|枚举:0~100 常见|写入:创建规则|FK:无|红线:精度例外列;参与计算须走整数运算(分×比例→四舍五入)
-	MinPayment      string  `gorm:"column:min_payment;type:decimal(12,2);not null;default:0.00" json:"minPayment"`                                     // 最低课酬标准(元)|枚举:无|写入:创建规则|FK:无|红线:<= max_payment(0 表示不限制)
-	MaxPayment      string  `gorm:"column:max_payment;type:decimal(12,2);not null;default:0.00" json:"maxPayment"`                                     // 最高课酬标准(元)|枚举:无|写入:创建规则|FK:无|红线:0 表示不限制
-	Reward          string  `gorm:"column:reward;type:decimal(12,2);not null;default:0.00" json:"reward"`                                              // 额外课酬(元)|枚举:无|写入:创建规则|FK:无|红线:在比例基础上追加
-	TeacherCert     bool    `gorm:"index:idx_cert_exp,priority:1;column:teacher_cert;not null;default:0" json:"teacherCert"`                           // 是否持证(bit(1)→tinyint(1)/bool)|枚举:false 无证/true 持证|写入:创建规则|FK:弱关联 t_teacher_information.teacher_cert|红线:第二级回退判据(持证通用)
-	TeacherExpMin   string  `gorm:"index:idx_cert_exp,priority:2;column:teacher_exp_min;type:decimal(6,2);not null;default:0.00" json:"teacherExpMin"` // 教龄下限(年;旧 float(10,3) → 2 位)|枚举:无|写入:创建规则|FK:无|红线:精度例外列;BETWEEN 匹配
-	TeacherExpMax   string  `gorm:"index:idx_cert_exp,priority:3;column:teacher_exp_max;type:decimal(6,2);not null;default:0.00" json:"teacherExpMax"` // 教龄上限(年)|枚举:无|写入:创建规则|FK:无|红线:>= teacher_exp_min
-	Actived         bool    `gorm:"column:actived;not null;default:0" json:"actived"`                                                                  // 是否生效(bit(1)→tinyint(1)/bool)|枚举:false/true|写入:启用/停用|FK:无|红线:仅 true 参与匹配
-	Memo            *string `gorm:"column:memo;type:longtext" json:"memo,omitempty"`                                                                   // 备注|枚举:无|写入:创建/编辑|FK:无|红线:脱敏
+	ID              int64   `gorm:"column:id;primaryKey;autoIncrement" json:"id"`                                                                       // 明细 ID(D3 自增)|枚举:无|写入:INSERT 自动|FK:无|红线:试算按 id ASC 取首条命中(回退优先级靠查询顺序)
+	CoursePaymentID int64   `gorm:"index:idx_payment;column:course_payment_id;not null" json:"coursePaymentId"`                                         // 课酬方案 ID(原 course_paymentid)|枚举:无|写入:创建规则时|FK:→ t_course_payment.id|红线:方案停用即整组失效
+	GradeID         int32   `gorm:"column:grade_id;not null;default:-1" json:"gradeId"`                                                                 // 年级 ID(-1 所有/0 混合)|枚举:−1 通配 + 年级字典|写入:创建规则|FK:→ 教学域年级|红线:−1 通配逻辑不得写成 `= -1` 强制条件
+	SubjectID       int32   `gorm:"column:subject_id;not null;default:-1" json:"subjectId"`                                                             // 科目 ID(-1 所有/0 混合)|枚举:−1 通配 + 科目字典|写入:创建规则|FK:→ 教学域科目|红线:同上
+	ClassType       int32   `gorm:"index:idx_teacher_classify,priority:2;column:class_type;not null;default:0" json:"classType"`                        // 班级类型(对应 t_courses_classify.class_type)|枚举:班型编码|写入:创建规则|FK:弱关联 t_courses_classify.class_type|红线:匹配维度
+	ClassifyID      int64   `gorm:"index:idx_teacher_classify,priority:3;column:classify_id;not null;default:0" json:"classifyId"`                      // 课程分类 ID(原 classifyid;个人配置维度)|枚举:无|写入:创建规则|FK:→ t_courses_classify.id|红线:0=不限定分类
+	TeacherID       *string `gorm:"index:idx_teacher_classify,priority:1;column:teacher_id;size:26" json:"teacherId,omitempty"`                         // 老师(ULID;NULL/空=通用规则)|枚举:无|写入:个人规则时|FK:→ t_user(逻辑)|红线:三级回退的第一级判据(个人优先)
+	Name            *string `gorm:"column:name;size:300" json:"name,omitempty"`                                                                         // 计算方式名称|枚举:无|写入:创建规则|FK:无|红线:展示转义
+	Percentage      string  `gorm:"column:percentage;type:decimal(5,1);not null;default:0.0" json:"percentage"`                                         // 课酬比例(百分比数字部分,1 位小数;旧 float(10,1))|枚举:0~100 常见|写入:创建规则|FK:无|红线:精度例外列;参与计算须走整数运算(分×比例→四舍五入)
+	MinPayment      string  `gorm:"column:min_payment;type:decimal(12,2);not null;default:0.00" json:"minPayment"`                                      // 最低课酬标准(元)|枚举:无|写入:创建规则|FK:无|红线:<= max_payment(0 表示不限制)
+	MaxPayment      string  `gorm:"column:max_payment;type:decimal(12,2);not null;default:0.00" json:"maxPayment"`                                      // 最高课酬标准(元)|枚举:无|写入:创建规则|FK:无|红线:0 表示不限制
+	Reward          string  `gorm:"column:reward;type:decimal(12,2);not null;default:0.00" json:"reward"`                                               // 额外课酬(元)|枚举:无|写入:创建规则|FK:无|红线:在比例基础上追加
+	TeacherCert     bool    `gorm:"index:idx_cert_exp,priority:1;column:teacher_cert;not null;default:0" json:"teacherCert"`                            // 是否持证(bit(1)→tinyint(1)/bool)|枚举:false 无证/true 持证|写入:创建规则|FK:弱关联 t_teacher_information.teacher_cert|红线:第二级回退判据(持证通用)
+	TeacherExpMin   string  `gorm:"index:idx_cert_exp,priority:2;column:teacher_exp_min;type:decimal(6,3);not null;default:0.000" json:"teacherExpMin"` // 教龄下限(年;旧 float(10,3) → **保留 3 位**)|枚举:无|写入:创建规则|FK:无|红线:精度例外列;BETWEEN 匹配;**收敛到 2 位会改变区间命中集合**(0.999→1.00 ⇒ [0.000,1.000] 与 [1.000,1.999] 并列命中,课酬按错档位算)
+	TeacherExpMax   string  `gorm:"index:idx_cert_exp,priority:3;column:teacher_exp_max;type:decimal(6,3);not null;default:0.000" json:"teacherExpMax"` // 教龄上限(年;**保留 3 位**,理由同 teacher_exp_min)|枚举:无|写入:创建规则|FK:无|红线:>= teacher_exp_min
+	Actived         bool    `gorm:"column:actived;not null;default:0" json:"actived"`                                                                   // 是否生效(bit(1)→tinyint(1)/bool)|枚举:false/true|写入:启用/停用|FK:无|红线:仅 true 参与匹配
+	Memo            *string `gorm:"column:memo;type:longtext" json:"memo,omitempty"`                                                                    // 备注|枚举:无|写入:创建/编辑|FK:无|红线:脱敏
 }
 
 // TableName 指定表名。
@@ -75,11 +77,11 @@ func (CoursePaymentDetail) TableName() string { return "t_course_payment_detail"
 // 作用域:**无** institution_id/campus_id(教师是平台级人员属性,跨机构共享)——
 // Repository 不继承 BaseRepo,越权断言在 Service 层(设计 §1.7 例外一)。
 type TeacherInformation struct {
-	TeacherID   string     `gorm:"column:teacher_id;primaryKey;size:26;not null" json:"teacherId"` // 老师(ULID;原 teacherid,主键)|枚举:无|写入:首次填资质时|FK:→ t_user(逻辑)|红线:一人一行
-	TeacherCert bool       `gorm:"column:teacher_cert;not null;default:0" json:"teacherCert"`      // 教师资格证(bit(1)→tinyint(1)/bool;0 无/1 有)|枚举:false/true|写入:资质维护|FK:无|红线:课酬第二级回退判据
-	TeacherExp  *time.Time `gorm:"column:teacher_exp;type:date" json:"teacherExp,omitempty"`       // 教龄起算日(date)|枚举:无|写入:资质维护|FK:无|红线:教龄年数由本列与试算日期差算出,勿存冗余年数
-	CreatedAt   time.Time  `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"createdAt"`                    // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:勿手改
-	UpdatedAt   time.Time  `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updatedAt"`                    // 更新时间(datetime(3),ON UPDATE)|枚举:无|写入:DB 自动|FK:无|红线:勿手改
+	TeacherID   string     `gorm:"column:teacher_id;primaryKey;size:26;not null" json:"teacherId"`        // 老师(ULID;原 teacherid,主键)|枚举:无|写入:首次填资质时|FK:→ t_user(逻辑)|红线:一人一行
+	TeacherCert bool       `gorm:"column:teacher_cert;not null;default:0" json:"teacherCert"`             // 教师资格证(bit(1)→tinyint(1)/bool;0 无/1 有)|枚举:false/true|写入:资质维护|FK:无|红线:课酬第二级回退判据
+	TeacherExp  *time.Time `gorm:"column:teacher_exp;type:date" json:"teacherExp,omitempty"`              // 教龄起算日(date)|枚举:无|写入:资质维护|FK:无|红线:教龄年数由本列与试算日期差算出,勿存冗余年数
+	CreatedAt   time.Time  `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"createdAt"` // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:勿手改
+	UpdatedAt   time.Time  `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updatedAt"` // 更新时间(datetime(3),ON UPDATE)|枚举:无|写入:DB 自动|FK:无|红线:勿手改
 }
 
 // TableName 指定表名。
@@ -104,8 +106,8 @@ type TeacherCoursesPercentage struct {
 	MinReward        string     `gorm:"column:min_reward;type:decimal(12,2);not null;default:0.00" json:"minReward"`                // 最小课酬(元/一次课)|枚举:无|写入:配置时|FK:无|红线:0=不限制
 	MinRewardExtend  *string    `gorm:"column:min_reward_extend;size:500" json:"minRewardExtend,omitempty"`                         // 最小课酬扩展(原样保留)|枚举:旧扩展串格式|写入:配置时|FK:无|红线:同上
 	EffectiveDate    *time.Time `gorm:"column:effective_date;type:date" json:"effectiveDate,omitempty"`                             // 课酬生效日期(date)|枚举:无|写入:配置时|FK:无|红线:取 <= 试算日期的最新一条
-	CreatedAt        time.Time  `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"createdAt"`                                                // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:勿手改
-	UpdatedAt        time.Time  `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updatedAt"`                                                // 更新时间(datetime(3),ON UPDATE)|枚举:无|写入:DB 自动|FK:无|红线:勿手改
+	CreatedAt        time.Time  `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"createdAt"`                      // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:勿手改
+	UpdatedAt        time.Time  `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updatedAt"`                      // 更新时间(datetime(3),ON UPDATE)|枚举:无|写入:DB 自动|FK:无|红线:勿手改
 }
 
 // TableName 指定表名。
@@ -128,18 +130,18 @@ func (Expenditure) TableName() string { return "t_expenditure" }
 //
 // year/month 为报表分桶冗余列(原 year year 类型 / month tinyint),保留以避免月度报表改 SQL。
 type ExpenditureLog struct {
-	ID            int64     `gorm:"column:id;primaryKey;autoIncrement" json:"id"`                                                        // 记录 ID(D3 自增)|枚举:无|写入:INSERT 自动|FK:无|红线:对外单号是 biz_no
-	BizNo         string    `gorm:"uniqueIndex:uk_biz_no;column:biz_no;size:32;not null" json:"bizNo"`                                   // 业务单号(旧 UUID 原样保留;UNIQUE)|枚举:无|写入:记账时|FK:迁移/回滚锚点|红线:不可变
-	InstitutionID int64     `gorm:"index:idx_inst_campus_time,priority:1;column:institution_id;not null;default:0" json:"institutionId"` // 机构 ID(原 schoolid;作用域列)|枚举:无|写入:记账时|FK:→ t_institution|红线:scoping 强依赖
-	CampusID      int64     `gorm:"index:idx_inst_campus_time,priority:2;column:campus_id;not null;default:0" json:"campusId"`           // 校区 ID(原 campusid;作用域列)|枚举:无|写入:记账时|FK:→ t_campus|红线:同上
-	ExpenditureID int64     `gorm:"index:idx_expenditure;column:expenditure_id;not null;default:0" json:"expenditureId"`                 // 收支类型 ID(原 expenditureid)|枚举:无|写入:记账时|FK:→ t_expenditure.id|红线:决定支出/收入方向
-	Cost          string    `gorm:"column:cost;type:decimal(12,2);not null;default:0.00" json:"cost"`                                    // 费用(元;旧 float(10,3) 收敛)|枚举:无|写入:记账时|FK:无|红线:符号由 type 决定,本列存正数
-	CreateUserID  *string   `gorm:"column:create_user_id;size:26" json:"createUserId,omitempty"`                                         // 创建人(ULID;原 create_userid)|枚举:无|写入:记账时|FK:→ t_user(逻辑)|红线:禁止旧 int id
-	CreateTime    time.Time `gorm:"index:idx_inst_campus_time,priority:3;column:create_time;not null;default:CURRENT_TIMESTAMP" json:"createTime"`                 // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:勿手改
-	Year          *int16    `gorm:"index:idx_year_month,priority:1;column:year" json:"year,omitempty"`                                   // 收支年份(报表分桶冗余列,原 year 类型)|枚举:如 2026|写入:记账时冗余|FK:无|红线:与 create_time 年份须一致(一致性校验)
-	Month         *int8     `gorm:"index:idx_year_month,priority:2;column:month" json:"month,omitempty"`                                 // 收支月份(报表分桶冗余列,原 month tinyint)|枚举:1~12|写入:记账时冗余|FK:无|红线:同上
-	Remark        *string   `gorm:"column:remark;type:longtext" json:"remark,omitempty"`                                                 // 备注|枚举:无|写入:记账时|FK:无|红线:脱敏
-	Imgs          *string   `gorm:"column:imgs;type:longtext" json:"-"`                                                                  // 图片地址(JSON 直存)|枚举:无|写入:记账时|FK:无|红线:**凭证可能含敏感信息,json:"-"**(按需授权下载)
+	ID            int64     `gorm:"column:id;primaryKey;autoIncrement" json:"id"`                                                                  // 记录 ID(D3 自增)|枚举:无|写入:INSERT 自动|FK:无|红线:对外单号是 biz_no
+	BizNo         string    `gorm:"uniqueIndex:uk_biz_no;column:biz_no;size:32;not null" json:"bizNo"`                                             // 业务单号(旧 UUID 原样保留;UNIQUE)|枚举:无|写入:记账时|FK:迁移/回滚锚点|红线:不可变
+	InstitutionID int64     `gorm:"index:idx_inst_campus_time,priority:1;column:institution_id;not null;default:0" json:"institutionId"`           // 机构 ID(原 schoolid;作用域列)|枚举:无|写入:记账时|FK:→ t_institution|红线:scoping 强依赖
+	CampusID      int64     `gorm:"index:idx_inst_campus_time,priority:2;column:campus_id;not null;default:0" json:"campusId"`                     // 校区 ID(原 campusid;作用域列)|枚举:无|写入:记账时|FK:→ t_campus|红线:同上
+	ExpenditureID int64     `gorm:"index:idx_expenditure;column:expenditure_id;not null;default:0" json:"expenditureId"`                           // 收支类型 ID(原 expenditureid)|枚举:无|写入:记账时|FK:→ t_expenditure.id|红线:决定支出/收入方向
+	Cost          string    `gorm:"column:cost;type:decimal(12,2);not null;default:0.00" json:"cost"`                                              // 费用(元;旧 float(10,3) 收敛)|枚举:无|写入:记账时|FK:无|红线:符号由 type 决定,本列存正数
+	CreateUserID  *string   `gorm:"column:create_user_id;size:26" json:"createUserId,omitempty"`                                                   // 创建人(ULID;原 create_userid)|枚举:无|写入:记账时|FK:→ t_user(逻辑)|红线:禁止旧 int id
+	CreateTime    time.Time `gorm:"index:idx_inst_campus_time,priority:3;column:create_time;not null;default:CURRENT_TIMESTAMP" json:"createTime"` // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:勿手改
+	Year          *int16    `gorm:"index:idx_year_month,priority:1;column:year" json:"year,omitempty"`                                             // 收支年份(报表分桶冗余列,原 year 类型)|枚举:如 2026|写入:记账时冗余|FK:无|红线:与 create_time 年份须一致(一致性校验)
+	Month         *int8     `gorm:"index:idx_year_month,priority:2;column:month" json:"month,omitempty"`                                           // 收支月份(报表分桶冗余列,原 month tinyint)|枚举:1~12|写入:记账时冗余|FK:无|红线:同上
+	Remark        *string   `gorm:"column:remark;type:longtext" json:"remark,omitempty"`                                                           // 备注|枚举:无|写入:记账时|FK:无|红线:脱敏
+	Imgs          *string   `gorm:"column:imgs;type:longtext" json:"-"`                                                                            // 图片地址(JSON 直存)|枚举:无|写入:记账时|FK:无|红线:**凭证可能含敏感信息,json:"-"**(按需授权下载)
 }
 
 // TableName 指定表名。

+ 1 - 1
internal/academic/model/testdata/academic-model-ddl.sha256

@@ -2,4 +2,4 @@
 # target=testdata/academic-model-ddl.sql(academic 27 表目标态 DDL fixture)
 # 归一规则:仅 \r\n -> \n(单 CR / 末尾换行 / BOM / 行尾空白一律不归一)
 # 纪律:DDL 变更须同一提交内同步「文档副本 / fixture / 本文件」三处
-a9220e7a5b04cfa4846202b57ddb9470546a03540d41934fe208e81c4b7e10bc
+25797302b71fd74e4af8632ebb27a629555bc20f5ef296a16631c25880f4fbdf

+ 3 - 3
internal/academic/model/testdata/academic-model-ddl.sql

@@ -746,8 +746,8 @@ CREATE TABLE `t_course_payment_detail` (
   `max_payment`       DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '最高课酬标准(元)',
   `reward`            DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '额外课酬(元)',
   `teacher_cert`      TINYINT(1)    NOT NULL DEFAULT 0 COMMENT '是否持证(bit(1)→tinyint(1)/bool)',
-  `teacher_exp_min`   DECIMAL(6,2)  NOT NULL DEFAULT 0.00 COMMENT '教龄下限(年)',
-  `teacher_exp_max`   DECIMAL(6,2)  NOT NULL DEFAULT 0.00 COMMENT '教龄上限(年)',
+  `teacher_exp_min`   DECIMAL(6,3)  NOT NULL DEFAULT 0.000 COMMENT '教龄下限(年;源 float(10,3) 保留 3 位,收敛会改变区间命中集合)',
+  `teacher_exp_max`   DECIMAL(6,3)  NOT NULL DEFAULT 0.000 COMMENT '教龄上限(年;源 float(10,3) 保留 3 位,收敛会改变区间命中集合)',
   `actived`           TINYINT(1)    NOT NULL DEFAULT 0 COMMENT '是否生效(bit(1)→tinyint(1)/bool)',
   `memo`              LONGTEXT      DEFAULT NULL COMMENT '备注',
   PRIMARY KEY (`id`),
@@ -952,7 +952,7 @@ SET FOREIGN_KEY_CHECKS = 1;
 --   · 所有 institution_id/campus_id = bigint
 --   · 所有金额列(balance/spend/total_*/special_expenses/money/cost/receivable/unpaid/…)
 --     = decimal(12,2) —— 例外(精度保留):t_courses.duration decimal(5,1)、
---     t_course_payment_detail.percentage decimal(5,1)、teacher_exp_min/max decimal(6,2)
+--     t_course_payment_detail.percentage decimal(5,1)、teacher_exp_min/max decimal(6,3)(教龄非金额,保留 3 位)
 --   · 所有时间列 = datetime(3)(例外:t_teacher_information.teacher_exp / effective_date = date)
 --   · 字符集全部 utf8mb4 / utf8mb4_general_ci;ROW_FORMAT=Dynamic
 --   · 7 张旧 UUID 表 biz_no 均为 VARCHAR(32) NOT NULL + UNIQUE

+ 2 - 2
internal/academic/repository/payroll_repo.go

@@ -405,14 +405,14 @@ const payrollWildcardID int32 = -1
 // **NULL/"" = 通用规则**(旧库用 0 表示,ULID 化后没有"0 号老师"),故条件是
 // `(teacher_id IS NULL OR teacher_id = "")`——**不能**写成 `teacher_id = '0'`(那永远不命中)。
 //
-// `inTeacherExp BETWEEN exp_min AND exp_max`:两端**闭区间**;教龄以 `decimal(6,2)` 字符串入场
+// `inTeacherExp BETWEEN exp_min AND exp_max`:两端**闭区间**;教龄以 `decimal(6,3)` 字符串入场
 // (`"3.50"`),由数据库按 NUMERIC 语义比较,**不在 Go 侧转 float64**(避免 3.50 vs 3.4999999 的边界抖动)。
 //
 // 入参:
 //   - ctx;schemeID(<=0 → ErrBadParam);
 //   - cert 是否持证档(true=② 持证通用 / false=③ 无证通用);
 //   - classType 班型;gradeID/subjectID 分类维度(`-1` 通配规则由表侧提供);
-//   - teacherExp 教龄(**已钳 0 的** decimal(6,2) 字符串,如 "0.00";空 → ErrBadParam)。
+//   - teacherExp 教龄(**已钳 0 的** decimal(6,3) 字符串,如 "0.000";空 → ErrBadParam)。
 //
 // 出参:*model.CoursePaymentDetail;无命中 (nil, nil);ErrBadParam / ErrDBError。
 func (r *PayrollRepo) FindGeneralRule(

+ 46 - 0
internal/academic/repository/payroll_repo_test.go

@@ -404,3 +404,49 @@ func wantRepoErr(t *testing.T, err error, code int, what string) {
 		t.Fatalf("%s:期望错误码 %d,实得 %d(%s)", what, code, appErr.Code, appErr.Msg)
 	}
 }
+
+// TestPayrollRepoTeacherExpThreeDecimalScale 教龄列保留 3 位精度时,区间边界的归属必须正确(P0 回归)。
+//
+// 场景(对齐 dump 首行 `0.000 / 0.999` 与下一档 `1.000 / 1.999`):
+// 教龄恰为 `1.000` 时**只能**命中 `[1.000, 1.999]`,不得命中 `[0.000, 0.999]`。
+//
+// 两种失败形态都能被本用例抓住:
+//  1. **列精度被收敛**(`decimal(6,3)` → `(6,2)`):MySQL 侧 `0.999` 会存成 `1.00`,两档并列命中,
+//     `id ASC` 取到低档 ⇒ 本用例红。⚠ sqlite 不强制 scale,故这一形态在本仓由
+//     `model.TestModelsColumnByColumn`(tag ↔ DDL 逐列比对)接力守。
+//  2. **比较运算符写错**(`<=`/`>=` 写成 `<`/`>`,或把 BETWEEN 拆成两段错一个):边界值直接漏/多 ⇒ 立刻红。
+func TestPayrollRepoTeacherExpThreeDecimalScale(t *testing.T) {
+	db := newPayrollRepoDB(t)
+	ctx := context.Background()
+	repo := NewPayrollRepo(db, payrollRepoScope())
+
+	insertDetail(t, db, 1, 77, nil, false, 1000, -1, -1, "0.000", "0.999")
+	insertDetail(t, db, 91, 77, nil, false, 1000, -1, -1, "1.000", "1.999")
+
+	cases := []struct {
+		exp  string
+		want int64
+		why  string
+	}{
+		{"1.000", 91, "整数年只命中上一档(低档上限 0.999 不含 1.0)"},
+		{"0.999", 1, "上界闭区间:0.999 ∈ 低档"},
+		{"1.999", 91, "上界闭区间:1.999 ∈ 高档"},
+		{"0.000", 1, "下界闭区间:0 ∈ 低档"},
+		{"2.000", 0, "出界:不应命中任何档"},
+	}
+	for _, tc := range cases {
+		got, err := repo.FindGeneralRule(ctx, 77, false, 1000, 7, 3, tc.exp)
+		if err != nil {
+			t.Fatalf("教龄 %s 查询失败: %v", tc.exp, err)
+		}
+		if tc.want == 0 {
+			if got != nil {
+				t.Fatalf("教龄 %s 应无命中,实得 %+v(%s)", tc.exp, got, tc.why)
+			}
+			continue
+		}
+		if got == nil || got.ID != tc.want {
+			t.Fatalf("教龄 %s 命中 %+v,期望 detail=%d(%s)", tc.exp, got, tc.want, tc.why)
+		}
+	}
+}

+ 12 - 7
internal/academic/service/payroll_service.go

@@ -395,21 +395,26 @@ func buildPayrollQuote(detail *model.CoursePaymentDetail) (*PayrollQuote, error)
 	}, nil
 }
 
-// formatTeacherExp 把教龄(年)格式化成 `decimal(6,2)` 字符串(供 SQL 做 BETWEEN 比较)。
+// formatTeacherExp 把教龄(年)格式化成 `decimal(6,3)` 字符串(供 SQL 做 BETWEEN 比较)。
 //
 // 入参:exp 教龄(年;dump 为 FLOAT)。负数 → 按 0(dump 763609)。
-// 出参:形如 "3.50" 的定点字符串(**不经 float 参与累加**:只做一次"×100 取整"换算,
+// 出参:形如 "3.500" 的定点字符串(**不经 float 参与累加**:只做一次"×1000 取整"换算,
 // 之后全部由数据库按 decimal 语义比较,避免 3.4999999 之类的二进制尾差把边界行判掉)。
+//
+// ⚠ **小数位数必须与列 scale 一致 = 3**(列是 `decimal(6,3)`,源 `float(10,3)`):
+// 这不是格式美观问题 —— 教龄区间靠 `BETWEEN exp_min AND exp_max` 判定,
+// 若列被收敛成 2 位,`0.999` 会存成 `1.00`,使 `[0.000,1.000]` 与 `[1.000,1.999]` **并列命中**,
+// 课酬按错档位算(D7 双跑必红)。P0 修复记录见 model.CoursePaymentDetail 的列注释。
 func formatTeacherExp(exp float64) string {
 	if math.IsNaN(exp) || exp < 0 {
 		exp = 0
 	}
-	if math.IsInf(exp, 0) || exp > 9999 {
-		// decimal(6,2) 上限 9999.99;超界直接夹到上限(教龄超过一万年在业务上无意义)
-		exp = 9999
+	if math.IsInf(exp, 0) || exp > 999 {
+		// decimal(6,3) 上限 999.999;超界直接夹到上限(教龄超过一千年在业务上无意义)
+		exp = 999
 	}
-	hundredths := int64(math.Round(exp * 100))
-	return fmt.Sprintf("%d.%02d", hundredths/100, hundredths%100)
+	thousandths := int64(math.Round(exp * 1000))
+	return fmt.Sprintf("%d.%03d", thousandths/1000, thousandths%1000)
 }
 
 // isNotFoundErr 判断错误是否为"记录不存在"(ErrNotFound / code 3001)。

+ 87 - 0
internal/academic/service/payroll_service_test.go

@@ -14,6 +14,7 @@ package service
 
 import (
 	"context"
+	"math"
 	"path/filepath"
 	"strconv"
 	"testing"
@@ -597,3 +598,89 @@ func TestQuotePayrollAliasDelegates(t *testing.T) {
 		t.Fatalf("别名与主方法结果不一致:%+v vs %+v", *viaAlias, *viaMain)
 	}
 }
+
+// ---------------------------------------------------------------- P0:教龄列精度(decimal(6,3))
+
+// TestQuotePayrollTeacherExpBoundaryOneYearHitsUpperBand 教龄恰好为整数时只能命中**上**一档。
+//
+// 背景(P0,架构师-2 AC-10 审计):源列 `teacher_exp_min/max` 是 `float(10,3)`,
+// 目标态原写 `DECIMAL(6,2)` —— 会把 `0.999` 存成 `1.00`,使
+// `[0.000, 0.999]` 与 `[1.000, 1.999]` 在教龄 = 1.0 处**并列命中**,
+// 再靠 `ORDER BY grade_id DESC, subject_id DESC, id ASC` 取到**低档**(id=1)⇒ 课酬按错档位算。
+// 修法是把两列改为 `DECIMAL(6,3)`(保留 3 位),本用例锁定"1.0 只命中 1.000~1.999 档"。
+//
+// ⚠ **诚实说明(别把这条当成 (6,2)→(6,3) 的自证)**:本仓单测栈是 sqlite,它的 NUMERIC 亲和性
+// **不强制 decimal 的 scale**(插入 0.999 不会因声明 (6,2) 而被舍成 1.00)。
+// 因此这条用例守的是**比较语义**(`BETWEEN` 两端闭 + 边界归属),
+// 真正守"列精度不能被收敛"的是 schema 门禁 `model.TestModelsColumnByColumn`
+// (struct tag ↔ DDL 逐列比对,实测:只改 DDL 不改 tag 时它立刻红)。
+func TestQuotePayrollTeacherExpBoundaryOneYearHitsUpperBand(t *testing.T) {
+	db := newPayrollServiceDB(t)
+	teacher := fundUserID(730)
+	seedPayrollClassify(t, db, 7001, 7, 3)
+	seedPayrollScheme(t, db, 61, 1, 10, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
+	// 低档:0.000 ~ 0.999(dump 实测首行形态)
+	seedPayrollDetail(t, db, payrollDetail{
+		ID: 1, SchemeID: 61, Cert: false, ClassType: payrollClassType,
+		GradeID: -1, SubjectID: -1,
+		Percentage: "10.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "0.00",
+		ExpMin: "0.000", ExpMax: "0.999",
+	})
+	// 高档:1.000 ~ 1.999
+	seedPayrollDetail(t, db, payrollDetail{
+		ID: 91, SchemeID: 61, Cert: false, ClassType: payrollClassType,
+		GradeID: -1, SubjectID: -1,
+		Percentage: "20.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "0.00",
+		ExpMin: "1.000", ExpMax: "1.999",
+	})
+	svc := NewPayrollService(db, fundSuperScope())
+
+	cases := []struct {
+		exp  float64
+		want int64
+		why  string
+	}{
+		{1.0, 91, "教龄恰为 1.0 ⇒ 只命中 1.000~1.999 档(若命中 1 则说明低档上限被舍成 1.00)"},
+		{0.999, 1, "上界闭区间:0.999 仍属低档"},
+		{1.999, 91, "上界闭区间:1.999 仍属高档"},
+		{0.0, 1, "下界闭区间:0 属低档"},
+	}
+	for _, tc := range cases {
+		quote, err := svc.QuoteTeacherReward(context.Background(), payrollInput(teacher, tc.exp))
+		if err != nil {
+			t.Fatalf("教龄 %.3f 应命中:%v(%s)", tc.exp, err, tc.why)
+		}
+		if quote.DetailID != tc.want {
+			t.Fatalf("教龄 %.3f 命中 detail=%d,期望 %d(%s)", tc.exp, quote.DetailID, tc.want, tc.why)
+		}
+	}
+
+	// 出界:2.0 不属任何档 → 3112(证明区间下界不是 `<=` 放宽)
+	_, err := svc.QuoteTeacherReward(context.Background(), payrollInput(teacher, 2.0))
+	wantAppErr(t, err, apperr.CodePayrollPlanMissing, "教龄 2.0 出界")
+}
+
+// TestFormatTeacherExpThreeDecimals 教龄格式化必须输出**3 位**小数(与列 scale `decimal(6,3)` 对齐)。
+//
+// 自证失败形式:把实现改回 2 位(`%d.%02d`)→ 本用例立刻红。
+// 为什么位数要对齐列 scale:教龄参与 `BETWEEN exp_min AND exp_max` 的数值比较,
+// 2 位格式化会把 1.234 送成 1.23,边界值(如 exp_max=1.230)附近的可判性直接丢失。
+func TestFormatTeacherExpThreeDecimals(t *testing.T) {
+	cases := []struct {
+		in   float64
+		want string
+	}{
+		{1, "1.000"},
+		{1.2349, "1.235"},
+		{0, "0.000"},
+		{-3, "0.000"},   // 负值钳 0(dump 763609)
+		{0.999, "0.999"},
+		{1000, "999.000"}, // 超 decimal(6,3) 上限 → 夹到 999.999 以内
+		{math.NaN(), "0.000"},
+	}
+	for _, tc := range cases {
+		if got := formatTeacherExp(tc.in); got != tc.want {
+			t.Errorf("formatTeacherExp(%v) = %q,期望 %q", tc.in, got, tc.want)
+		}
+	}
+}