소스 검색

chore(mock): academic mock 按契约 v1.6~v1.9 全量回改 + 消费方同步 + vite proxy 去前缀

mock 侧(academic 63 端点契约对齐 v1.9,权威 = unified/03-migration/academic-frontend-contract-v1.md):
- v1.6 A1: 欠费字段 unpaid → remainderReceivable(端点 53/55)
- v1.6 A2: 分账 quota 改金额(元,两位小数字符串;0.00 合法);3111 判据=子额度>父额度(端点 40)
- v1.6 B1: 端点 10 spend 不写流水,仅回 {spend} 无 logId
- v1.6 B2: 端点 12 工单时间改 requestTime/responseTime(createdAt 不下发)
- v1.6 B3: 端点 11 流水行删 institutionId/campusId(流水表无作用域列)
- v1.6 B4: 端点 21 仪表盘改账户横截面 5 字段 + balanceDistribution(删 periodIncome 等)
- v1.6 B5: 端点 23 删 bizNo;端点 31 items 删 memo
- v1.6 C1/C2/C3: createAt→createTime;startAt/endAt→startTime/endTime;打卡 type/pay/cost/giveCost
- v1.6 E5/E7/E8/E9: numberSign 不可写→2002;3107 按状态判;ULID 统一 26 位;status 出数字码
- v1.7: 端点 51 divide[] = [{userId,type,amount}](删 level/quota)
- v1.8 课程域: 删 room(只有 address);week 改字符串("-1"/"0"~"6"/"1,3,5");to 含当日;
  realName 可空(null);3143 重复报名;端点 54 补入参且不写流水不动余额
- v1.9 课酬/收支/维护: 端点 58 两级结构(方案+明细,PUT 整体替换 details);端点 59 试算 9 字段
  (含 schemeId/schemeCampusId/schemeScope/ruleTier 溯源,只回规则参数);端点 60 删 direction、
  userId→createUserId、补 expenditureId、字典 {id,type,name,parentId}、POST /types 不做;
  端点 63 mode 缺失→2001 / 非法→2002

消费方同步(防静默空白列,mock+DTO+view 三方一致):
- api/academic/finance.ts: ExpenseLogDTO(createTime、删两作用域字段)、BalanceRequestDTO
  (requestTime/responseTime 可空)、spend() 返回删 logId
- api/academic/activity.ts: ActivityDTO(startTime/endTime、money、numberSign 改 number)
- views/academic/fee: 流水列 createTime、工单列 requestTime
- views/academic/activity: 起止列 startTime/endTime

vite.config.ts(契约 §4 四层口径 D1/D2):
- proxy /api 加 rewrite 去前缀(无条件、无例外)
- defineConfig 函数式 + loadEnv,VITE_API_PROXY_TARGET 从 .env 读(shell env 不可见)

质量门禁:vue-tsc 0 错 / vite build 0 错(主 chunk 31.13kB 远低于 1024 上限)/ eslint 0 错 /
dist grep 无 mock 特征串(VITE_API_MOCK/mulberry32/mock-invalid-token 均 0 命中)
Q 7 시간 전
부모
커밋
26a9d5125f

+ 9 - 7
src/api/academic/activity.ts

@@ -24,18 +24,20 @@ export interface ActivityDTO {
   name: string;
   /** 状态数字码(前端映射中文) */
   status: number;
-  /** 报名单号 */
-  numberSign: string;
+  /** 报名计数(number;v1.9 E8:活动更新时不可写) */
+  numberSign: number;
   /** 名额总数 */
   total: number;
   /** 已报名人数 */
   signed: number;
-  /** 是否可报名 */
+  /** 活动金额(v1.6 E6:充值赠送额度/老带新奖励额/特殊费用额,字符串两位小数) */
+  money: string;
+  /** 是否可报名(仅展示位) */
   signable: boolean;
-  /** 开始时间(RFC3339) */
-  startAt: string;
-  /** 结束时间(RFC3339) */
-  endAt: string;
+  /** 开始时间(RFC3339;v1.6 C2) */
+  startTime: string;
+  /** 结束时间(RFC3339;v1.6 C2) */
+  endTime: string;
 }
 
 /** 活动列表查询参数(作用域由 useScope().toQuery() 注入) */

+ 12 - 13
src/api/academic/finance.ts

@@ -9,7 +9,7 @@
  *   - 流水列表:useListPage<ExpenseLogDTO> fetcher=listLogs({...scope.toQuery(), userId, page, pageSize})
  *   - 各操作:直接调对应方法,后端恒 200 + body code;业务错误由调用方判 code
  *
- * 契约对齐(academic-frontend-contract-v1 v1.1,联调时按 v1.6 回改):
+ * 契约对齐(academic-frontend-contract-v1 v1.9;v1.6 B1/C1 已回改):
  *   - 金额恒字符串两位小数(元);时间 RFC3339;分页 PageData 五字段;
  *   - 写操作带 requestId 走幂等(重复提交 → repeated=true 回放首次)。
  */
@@ -18,24 +18,20 @@ import type { PageR, R } from '@/types/api';
 
 /* ================= 读 ================= */
 
-/** 资金流水 DTO(端点 11 列表行) */
+/** 资金流水 DTO(端点 11 列表行;v1.5:流水表无作用域列,不含 institutionId/campusId) */
 export interface ExpenseLogDTO {
   /** 流水 id(int64 主键) */
   id: number;
   /** 用户 id(canonical_user_id ULID 字符串) */
   userId: string;
-  /** 机构 id(作用域第一级) */
-  institutionId: number;
-  /** 校区 id(作用域第二级;0 = 机构级) */
-  campusId: number;
   /** 流水类型编码(100 充值 + / 101,201 消费 − / 207 退费 − / 208 清零 − / 106,206 转结 / 108 坏账 +) */
   type: number;
   /** 金额(字符串两位小数) */
   money: string;
-  /** 变动后余额(字符串两位小数) */
+  /** 变动后余额(字符串两位小数,全局账户快照) */
   balanceAfter: string;
-  /** 创建时间(RFC3339) */
-  createAt: string;
+  /** 业务时间(RFC3339;v1.6 C1:← legacy create_time) */
+  createTime: string;
   /** 备注 */
   remark: string;
 }
@@ -113,7 +109,10 @@ export interface BalanceRequestDTO {
   applicantId: string;
   approverId: string;
   remark: string;
-  createAt: string;
+  /** 申请时间(RFC3339;v1.6 B2:requestTime ← request_time) */
+  requestTime: string;
+  /** 审批时间(RFC3339;未审批时 null) */
+  responseTime: string | null;
 }
 
 /** 工单列表(端点 12,GET /api/admin/finance/requests)。 */
@@ -229,9 +228,9 @@ export function clearBalance(body: { userId: string; campusId: number; instituti
   return http.post<R<ClearBalanceResult>>('/api/admin/finance/cleared-balances', body).then((r) => r.data);
 }
 
-/** spend 扣减(端点 10,POST /api/admin/finance/spends;课时扣费)。 */
-export function spend(body: { userId: string; campusId: number; spendMoney: string; institutionId: number }): Promise<R<{ spend: string; logId: number }>> {
-  return http.post<R<{ spend: string; logId: number }>>('/api/admin/finance/spends', body).then((r) => r.data);
+/** spend 扣减(端点 10,POST /api/admin/finance/spends;课时扣费;v1.6 B1:旧库只 UPDATE spend 列不写流水 → 仅回 `{spend}`,无 logId)。 */
+export function spend(body: { userId: string; campusId: number; spendMoney: string; institutionId: number }): Promise<R<{ spend: string }>> {
+  return http.post<R<{ spend: string }>>('/api/admin/finance/spends', body).then((r) => r.data);
 }
 
 /** 建工单(端点 13,POST /api/admin/finance/requests)。 */

+ 116 - 62
src/api/mock/data.ts

@@ -59,7 +59,8 @@ export const campuses: MockCampus[] = [
   { campusId: 13, institutionId: 1, name: '高新校区' },
 ];
 
-const ulid = (n: number): string => `01J2M${String(n).padStart(19, '0')}`.slice(0, 26);
+// 契约 §1.5:canonical_user_id 恒 26 位 ULID(E8:原生成 23~24 位,补到 26)。
+const ulid = (n: number): string => `01J2M${String(n).padStart(21, '0')}`;
 
 export const users: MockUser[] = [
   { userId: ulid(101), realName: '张婷', personType: 'staff', phone: '135****3322', campusId: 11, role: 'teacher' },
@@ -151,7 +152,8 @@ export interface ExpenseLog {
   /** 金额(分,含方向符号:充值/坏账 +,消费/退费/清零 −) */
   money: number;
   balanceAfter: number;
-  createAt: string;
+  /** 业务时间(契约 C1:`createAt` 是第三种写法,正确名为 `createTime` ← legacy `create_time`) */
+  createTime: string;
   remark: string;
   requestId?: string;
 }
@@ -185,7 +187,7 @@ const logs: ExpenseLog[] = [];
       const positive = type === 100;
       const money = positive ? yuan * 100 : -yuan * 100;
       running = Math.max(running + money, -200000);
-      const createAt = rfc3339(BASE_MS - i * 3600_000 * randInt(1, 30));
+      const createTime = rfc3339(BASE_MS - i * 3600_000 * randInt(1, 30));
       logs.push({
         id: ++logSeq,
         userId: u.userId,
@@ -194,7 +196,7 @@ const logs: ExpenseLog[] = [];
         type,
         money,
         balanceAfter: running,
-        createAt,
+        createTime,
         remark: LOG_TYPE_REMARK[type] ?? '流水',
       });
     }
@@ -219,12 +221,12 @@ export function queryLogs(f: LogFilter): ExpenseLog[] {
       if (f.type !== undefined && l.type !== f.type) return false;
       if (f.institutionId && f.institutionId !== 1) return false;
       if (f.campusId && f.campusId !== 0 && l.campusId !== f.campusId) return false;
-      const t = Date.parse(l.createAt);
+      const t = Date.parse(l.createTime);
       if (from !== null && t < from) return false;
       if (to !== null && t > to) return false;
       return true;
     })
-    .sort((a, b) => (a.createAt < b.createAt ? 1 : a.createAt > b.createAt ? -1 : b.id - a.id));
+    .sort((a, b) => (a.createTime < b.createTime ? 1 : a.createTime > b.createTime ? -1 : b.id - a.id));
 }
 
 /** 追加一条流水(写端点共用;返回新 logId)。 */
@@ -240,7 +242,7 @@ export function appendLog(userId: string, campusId: number, type: number, money:
     type,
     money,
     balanceAfter: a.totalBalance,
-    createAt: rfc3339(BASE_MS),
+    createTime: rfc3339(BASE_MS),
     remark,
   };
   if (requestId) log.requestId = requestId;
@@ -261,7 +263,10 @@ export interface BalanceRequest {
   applicantId: string;
   approverId: string | null;
   remark: string;
-  createAt: string;
+  /** 申请时间(业务时间 ← request_time;v1.6 B2:`createdAt` 是迁移时生成的新列,不下发) */
+  requestTime: string;
+  /** 审批时间(业务时间 ← response_time;未审批为 null) */
+  responseTime: string | null;
 }
 const requestTypes = [100, 101, 202, 203, 300] as const;
 let reqSeq = 5000;
@@ -270,6 +275,7 @@ const requests: BalanceRequest[] = [];
   for (let i = 0; i < 60; i++) {
     const u = pick(users.filter((x) => x.personType === 'student'));
     const approved = pick([0, 0, 1, -1, -2] as const);
+    const requestTime = rfc3339(BASE_MS - i * 7200_000);
     requests.push({
       id: ++reqSeq,
       bizNo: `REQ${(700000 + i).toString()}`,
@@ -282,10 +288,11 @@ const requests: BalanceRequest[] = [];
       applicantId: u.userId,
       approverId: approved === 0 ? null : ulid(302),
       remark: '工单',
-      createAt: rfc3339(BASE_MS - i * 7200_000),
+      requestTime,
+      responseTime: approved === 0 ? null : rfc3339(Date.parse(requestTime) + 3600_000),
     });
   }
-  requests.sort((a, b) => (a.createAt < b.createAt ? 1 : -1));
+  requests.sort((a, b) => (a.requestTime < b.requestTime ? 1 : -1));
 })();
 export function queryRequests(f: { type?: number; approved?: number; userId?: string; institutionId?: number; campusId?: number }): BalanceRequest[] {
   return requests.filter((r) => {
@@ -313,7 +320,8 @@ export function createRequest(type: number, userId: string, campusId: number, mo
     applicantId,
     approverId: null,
     remark,
-    createAt: rfc3339(BASE_MS),
+    requestTime: rfc3339(BASE_MS),
+    responseTime: null,
   };
   requests.unshift(r);
   return r;
@@ -327,7 +335,8 @@ export interface ActivitySign {
   status: number; // 0 未审核 / 1 通过 / -1 取消
   recommendUserId: string | null;
   money: number; // 分
-  createAt: string;
+  /** 报名业务时间(契约 C1:`createAt`→`createTime`;端点 24/28 signeds 行) */
+  createTime: string;
 }
 export interface Activity {
   id: number;
@@ -339,19 +348,23 @@ export interface Activity {
   numberSign: number;
   total: number;
   signed: number;
+  /** 活动金额(分;v1.6 E6:活动级 `money`,充值赠送额度/老带新奖励额/特殊费用额,区别于 signed 行 money) */
+  money: number;
   signable: boolean;
-  startAt: string;
-  endAt: string;
+  /** 开始时间(契约 C2:源列 start_time,活动与排班统一 startTime,撤回 startAt 方案) */
+  startTime: string;
+  /** 结束时间(契约 C2:源列 end_time,统一 endTime) */
+  endTime: string;
   signeds: ActivitySign[];
 }
 let signSeq = 3000;
 function mkSign(activityId: number, userId: string, status: number, recommendUserId: string | null, money: number): ActivitySign {
-  return { id: ++signSeq, activityId, userId, status, recommendUserId, money, createAt: rfc3339(BASE_MS - randInt(1, 30) * 86400_000) };
+  return { id: ++signSeq, activityId, userId, status, recommendUserId, money, createTime: rfc3339(BASE_MS - randInt(1, 30) * 86400_000) };
 }
 const activities: Activity[] = [
   {
     id: 1, institutionId: 1, campusId: 11, type: 2, name: '老带新推荐(数学)', status: 1, numberSign: 1,
-    total: 100, signed: 6, signable: true, startAt: rfc3339(BASE_MS - 40 * 86400_000), endAt: rfc3339(BASE_MS + 20 * 86400_000),
+    total: 100, signed: 6, money: 20000, signable: true, startTime: rfc3339(BASE_MS - 40 * 86400_000), endTime: rfc3339(BASE_MS + 20 * 86400_000),
     signeds: [
       mkSign(1, ulid(201), 1, null, 20000),
       mkSign(1, ulid(202), 1, ulid(201), 20000),
@@ -363,22 +376,22 @@ const activities: Activity[] = [
   },
   {
     id: 2, institutionId: 1, campusId: 12, type: 3, name: '充值赠送 10%', status: 1, numberSign: 1,
-    total: 200, signed: 3, signable: true, startAt: rfc3339(BASE_MS - 20 * 86400_000), endAt: rfc3339(BASE_MS + 40 * 86400_000),
+    total: 200, signed: 3, money: 10000, signable: true, startTime: rfc3339(BASE_MS - 20 * 86400_000), endTime: rfc3339(BASE_MS + 40 * 86400_000),
     signeds: [mkSign(2, ulid(203), 1, null, 10000), mkSign(2, ulid(205), 1, null, 15000), mkSign(2, ulid(204), 0, null, 8000)],
   },
   {
     id: 3, institutionId: 1, campusId: 11, type: 4, name: '特殊费用减免', status: 0, numberSign: 1,
-    total: 50, signed: 1, signable: false, startAt: rfc3339(BASE_MS - 60 * 86400_000), endAt: rfc3339(BASE_MS - 5 * 86400_000),
+    total: 50, signed: 1, money: 5000, signable: false, startTime: rfc3339(BASE_MS - 60 * 86400_000), endTime: rfc3339(BASE_MS - 5 * 86400_000),
     signeds: [mkSign(3, ulid(202), 1, null, 5000)],
   },
   {
     id: 4, institutionId: 1, campusId: 13, type: 3, name: '新学期充值有礼', status: 1, numberSign: 1,
-    total: 150, signed: 0, signable: true, startAt: rfc3339(BASE_MS - 5 * 86400_000), endAt: rfc3339(BASE_MS + 35 * 86400_000),
+    total: 150, signed: 0, money: 15000, signable: true, startTime: rfc3339(BASE_MS - 5 * 86400_000), endTime: rfc3339(BASE_MS + 35 * 86400_000),
     signeds: [],
   },
   {
     id: 5, institutionId: 1, campusId: 12, type: 2, name: '老带新推荐(物理)', status: 1, numberSign: 1,
-    total: 80, signed: 2, signable: true, startAt: rfc3339(BASE_MS - 15 * 86400_000), endAt: rfc3339(BASE_MS + 25 * 86400_000),
+    total: 80, signed: 2, money: 30000, signable: true, startTime: rfc3339(BASE_MS - 15 * 86400_000), endTime: rfc3339(BASE_MS + 25 * 86400_000),
     signeds: [mkSign(5, ulid(203), 1, null, 30000), mkSign(5, ulid(205), 1, ulid(203), 30000)],
   },
 ];
@@ -402,11 +415,15 @@ export interface CourseRecord {
   courseId: number;
   teacherId: string;
   campusId: number;
-  room: string;
+  /** 地址(v1.8:源列 `address`;**无 room 列**——地址≠教室号,不得顶替) */
+  address: string;
+  /** 周(排班规则源列 week VARCHAR(50) DEFAULT '-1';"-1"=全部周 / "0".."6" / "1,3,5" 多值) */
+  week: string;
   startTime: string; // RFC3339
   endTime: string;
   status: 0 | 1 | 2; // 0 未开始 / 1 进行中 / 2 已结束
-  students: { userId: string; attendance: 0 | 1; payCost: number; giveCost: number }[];
+  /** 逐学生(C3:`attendance/payCost`→`type/pay/cost/giveCost`) */
+  students: { userId: string; type: number; pay: number; cost: number; giveCost: number }[];
 }
 export interface Course {
   id: number;
@@ -418,21 +435,23 @@ export interface Course {
   records: CourseRecord[];
 }
 let recordSeq = 6000;
-function mkRecord(courseId: number, teacherId: string, campusId: number, room: string, offsetDays: number, status: 0 | 1 | 2): CourseRecord {
+function mkRecord(courseId: number, teacherId: string, campusId: number, address: string, week: string, offsetDays: number, status: 0 | 1 | 2): CourseRecord {
   const start = BASE_MS + offsetDays * 86400_000;
+  const present = (on: boolean): number => (status === 2 && on ? 1 : 0); // 已结束课才有出勤 type=1(正常)
   return {
     id: ++recordSeq,
     courseId,
     teacherId,
     campusId,
-    room,
+    address,
+    week,
     startTime: rfc3339(start),
     endTime: rfc3339(start + 90 * 60_000),
     status,
     students: [
-      { userId: ulid(201), attendance: status === 2 ? 1 : 0, payCost: 10000, giveCost: 0 },
-      { userId: ulid(202), attendance: status === 2 ? 1 : 0, payCost: 10000, giveCost: 0 },
-      { userId: ulid(204), attendance: status === 2 ? 0 : 0, payCost: 10000, giveCost: 0 },
+      { userId: ulid(201), type: present(true), pay: 10000, cost: 10000, giveCost: 0 },
+      { userId: ulid(202), type: present(true), pay: 10000, cost: 10000, giveCost: 0 },
+      { userId: ulid(204), type: present(false), pay: 10000, cost: 10000, giveCost: 0 },
     ],
   };
 }
@@ -440,19 +459,19 @@ const courses: Course[] = [
   {
     id: 1, institutionId: 1, name: '数学 8 班', classify: '数学', status: 1, teacherIds: [ulid(101)],
     records: [
-      mkRecord(1, ulid(101), 11, 'A203', -3, 2),
-      mkRecord(1, ulid(101), 11, 'A203', 0, 0),
-      mkRecord(1, ulid(101), 11, 'A205', 2, 0),
-      mkRecord(1, ulid(101), 11, 'A203', 4, 0),
+      mkRecord(1, ulid(101), 11, '滨江校区 3 楼 305 教室', '1,3,5', -3, 2),
+      mkRecord(1, ulid(101), 11, '滨江校区 3 楼 305 教室', '1,3,5', 0, 0),
+      mkRecord(1, ulid(101), 11, '滨江校区 5 楼 502 教室', '2,4', 2, 0),
+      mkRecord(1, ulid(101), 11, '滨江校区 3 楼 305 教室', '-1', 4, 0),
     ],
   },
   {
     id: 2, institutionId: 1, name: '物理 3 班', classify: '物理', status: 1, teacherIds: [ulid(102)],
-    records: [mkRecord(2, ulid(102), 12, 'B102', -1, 1), mkRecord(2, ulid(102), 12, 'B102', 1, 0)],
+    records: [mkRecord(2, ulid(102), 12, '城西校区 1 楼 102 教室', '3', -1, 1), mkRecord(2, ulid(102), 12, '城西校区 1 楼 102 教室', '3', 1, 0)],
   },
   {
     id: 3, institutionId: 1, name: '英语 1 班', classify: '英语', status: 0, teacherIds: [ulid(101)],
-    records: [mkRecord(3, ulid(101), 11, 'C101', -7, 2)],
+    records: [mkRecord(3, ulid(101), 11, '滨江校区 4 楼 401 教室', '6', -7, 2)],
   },
 ];
 export function queryCourses(f: { classify?: string; status?: number; keyword?: string; institutionId?: number; campusId?: number }): Course[] {
@@ -477,24 +496,25 @@ export interface DivideNode {
   userId: string;
   realName: string;
   parentUserId: string | null;
-  quota: number; // 0~1 小数
+  /** 每次分成金额(元;A2:`quota` 是金额非 0~1 比率,源列 DECIMAL(12,2),与 total_income 同族;0.00 = 该链不分成) */
+  quota: number;
   cumulativeIncome: number; // 分
   children: DivideNode[];
 }
 const divideTree: DivideNode[] = [
   {
-    userId: ulid(303), realName: '超级管理员', parentUserId: null, quota: 1, cumulativeIncome: 0,
+    userId: ulid(303), realName: '超级管理员', parentUserId: null, quota: 500, cumulativeIncome: 0,
     children: [
       {
-        userId: ulid(101), realName: '张婷', parentUserId: ulid(303), quota: 0.3, cumulativeIncome: 1200000,
+        userId: ulid(101), realName: '张婷', parentUserId: ulid(303), quota: 150, cumulativeIncome: 1200000,
         children: [
-          { userId: ulid(201), realName: '王梓涵', parentUserId: ulid(101), quota: 0.1, cumulativeIncome: 300000, children: [] },
+          { userId: ulid(201), realName: '王梓涵', parentUserId: ulid(101), quota: 50, cumulativeIncome: 300000, children: [] },
           { userId: ulid(202), realName: '陈一诺', parentUserId: ulid(101), quota: 0, cumulativeIncome: 0, children: [] }, // quota=0(新节点默认)
         ],
       },
       {
-        userId: ulid(102), realName: '李强', parentUserId: ulid(303), quota: 0.2, cumulativeIncome: 800000,
-        children: [{ userId: ulid(203), realName: '李沐宸', parentUserId: ulid(102), quota: 0.15, cumulativeIncome: 200000, children: [] }],
+        userId: ulid(102), realName: '李强', parentUserId: ulid(303), quota: 100, cumulativeIncome: 800000,
+        children: [{ userId: ulid(203), realName: '李沐宸', parentUserId: ulid(102), quota: 30, cumulativeIncome: 200000, children: [] }],
       },
     ],
   },
@@ -514,29 +534,63 @@ export function findDivideNode(userId: string): DivideNode | undefined {
   return found;
 }
 
-/* ================= 课酬方案 ================= */
-export interface PayrollPlan {
-  id: number;
-  teacherId: string;
-  schoolId: number;
-  campusId: number;
+/* ================= 课酬方案(v1.9 两级结构:方案 + 明细) ================= */
+/** 课酬明细行(t_course_payment_detail;金额存分,教龄/比例为 number,percentage 为百分比数 0~100)。 */
+export interface PayrollDetail {
+  detailId: number;
+  /** 老师(可空;null = 机构级通用档) */
+  teacherId: string | null;
   classifyId: number;
-  teacherExp: number;
+  classType: number;
+  gradeId: number;
+  subjectId: number;
+  teacherCert: number;
+  /** 教龄区间(年,3 位小数;源列两列,不得合并为单值) */
+  teacherExpMin: number;
+  teacherExpMax: number;
+  /** 课酬比例(百分比数 0~100,1 位小数;A3:非 0~1 小数) */
   percentage: number;
+  /** 金额上下限 / 奖励(分) */
   min: number;
   max: number;
   reward: number;
-  actived: 0 | 1;
+  /** 计算方式名(明细级 name;方案级为 schemeName) */
+  displayName: string | null;
+  /** 明细级启停(方案级为 actived) */
+  detailActived: 0 | 1;
+}
+/** 课酬方案(t_course_payment;两级结构顶层)。 */
+export interface PayrollPlan {
+  id: number;
+  institutionId: number;
+  campusId: number; // 0 = 机构级方案
+  schemeName: string;
   effectDate: string;
+  /** 方案级启停(整案停用 = 不参与匹配) */
+  actived: 0 | 1;
+  details: PayrollDetail[];
 }
+let detailSeq = 90000;
 const payrollPlans: PayrollPlan[] = [
-  { id: 1, teacherId: ulid(101), schoolId: 1, campusId: 11, classifyId: 1, teacherExp: 8, percentage: 0.5, min: 50000, max: 200000, reward: 10000, actived: 1, effectDate: '2026-09-01' },
-  { id: 2, teacherId: ulid(102), schoolId: 1, campusId: 12, classifyId: 2, teacherExp: 5, percentage: 0.4, min: 40000, max: 150000, reward: 8000, actived: 1, effectDate: '2026-09-01' },
+  {
+    id: 1, institutionId: 1, campusId: 11, schemeName: '滨江校区 2026 秋季课酬方案', effectDate: '2026-09-01', actived: 1,
+    details: [
+      { detailId: ++detailSeq, teacherId: ulid(101), classifyId: 1, classType: 1, gradeId: 1, subjectId: 1, teacherCert: 1, teacherExpMin: 5, teacherExpMax: 15, percentage: 85, min: 90000, max: 150000, reward: 10000, displayName: '数学 5~15 年教龄', detailActived: 1 },
+      { detailId: ++detailSeq, teacherId: null, classifyId: 1, classType: 1, gradeId: -1, subjectId: -1, teacherCert: 0, teacherExpMin: 0, teacherExpMax: 5, percentage: 70, min: 60000, max: 90000, reward: 0, displayName: '数学 5 年内教龄(通用)', detailActived: 1 },
+    ],
+  },
+  {
+    id: 2, institutionId: 1, campusId: 12, schemeName: '城西校区 2026 秋季课酬方案', effectDate: '2026-09-01', actived: 1,
+    details: [
+      { detailId: ++detailSeq, teacherId: ulid(102), classifyId: 2, classType: 1, gradeId: 2, subjectId: 2, teacherCert: 0, teacherExpMin: 3, teacherExpMax: 10, percentage: 80, min: 80000, max: 120000, reward: 8000, displayName: '物理 3~10 年教龄', detailActived: 1 },
+      { detailId: ++detailSeq, teacherId: null, classifyId: 2, classType: 1, gradeId: -1, subjectId: -1, teacherCert: 0, teacherExpMin: 0, teacherExpMax: 3, percentage: 60, min: 50000, max: 80000, reward: 0, displayName: '物理 3 年内教龄(通用)', detailActived: 0 },
+    ],
+  },
 ];
 export function queryPayrollPlans(f: { actived?: number; classifyId?: number }): PayrollPlan[] {
   return payrollPlans.filter((p) => {
     if (f.actived !== undefined && p.actived !== f.actived) return false;
-    if (f.classifyId !== undefined && p.classifyId !== f.classifyId) return false;
+    if (f.classifyId !== undefined && !p.details.some((d) => d.classifyId === f.classifyId)) return false;
     return true;
   });
 }
@@ -544,24 +598,24 @@ export function findPayrollPlan(id: number): PayrollPlan | undefined {
   return payrollPlans.find((p) => p.id === id);
 }
 
-/* ================= 收支类型字典 + 收支记录 ================= */
+/* ================= 收支类型字典(v1.9:type 1=支出/2=收入,删 direction;补 id/parentId) ================= */
 export interface ExpenditureType {
+  id: number;
+  /** 收支方向编码:1=支出 / 2=收入(与流水 type 同事实,两表均无 direction 列) */
   type: number;
   name: string;
-  direction: 1 | -1; // 1 收 / -1 支
+  /** 上级类型 id(0 = 顶级) */
+  parentId: number;
 }
 export const expenditureTypes: ExpenditureType[] = [
-  { type: 101, name: '课程消费(校区)', direction: -1 },
-  { type: 104, name: '课程消费(学校)', direction: -1 },
-  { type: 105, name: '课程消费(学校)', direction: -1 },
-  { type: 201, name: '课程消费(校区)', direction: -1 },
-  { type: 204, name: '课程消费(学校)', direction: -1 },
-  { type: 205, name: '课程消费(学校)', direction: -1 },
-  { type: 300, name: '线下收入', direction: 1 },
-  { type: 301, name: '线上收入', direction: 1 },
+  { id: 1, type: 1, name: '课程消费', parentId: 0 },
+  { id: 2, type: 1, name: '课程消费(校区)', parentId: 1 },
+  { id: 3, type: 1, name: '课程消费(学校)', parentId: 1 },
+  { id: 4, type: 2, name: '线下收入', parentId: 0 },
+  { id: 5, type: 2, name: '线上收入', parentId: 0 },
 ];
-export function expenditureTypeOf(type: number): ExpenditureType | undefined {
-  return expenditureTypes.find((t) => t.type === type);
+export function expenditureTypeOf(id: number): ExpenditureType | undefined {
+  return expenditureTypes.find((t) => t.id === id);
 }
 
 /* ================= 幂等存储(写端点重复提交 → repeated=true) ================= */

+ 40 - 28
src/api/mock/routes/activities.ts

@@ -13,7 +13,7 @@ import { fail, ok, page } from '@/api/mock/envelope';
 import { toCents, fmt } from '@/api/mock/money';
 import { rfc3339 } from '@/api/mock/time';
 import { inScope, findUser, findActivity, queryActivities } from '@/api/mock/data';
-import type { ActivitySign } from '@/api/mock/data';
+import type { Activity, ActivitySign } from '@/api/mock/data';
 import type { MockCtx, MockRoute } from '@/api/mock/adapter';
 import type { R } from '@/types/api';
 
@@ -23,7 +23,7 @@ function checkScope(ctx: MockCtx): R<undefined> | undefined {
   return inScope(institutionId, campusId) ? undefined : fail(1004, '作用域越界:当前账号无权访问该机构/校区');
 }
 
-/** 报名行 → 列表项。 */
+/** 报名行 → 列表项(v1.6 C1:业务时间 `createTime`;端点 24/28 signeds[] 行)。 */
 function signRow(s: ActivitySign): Record<string, unknown> {
   return {
     id: s.id,
@@ -32,7 +32,26 @@ function signRow(s: ActivitySign): Record<string, unknown> {
     status: s.status,
     recommendUserId: s.recommendUserId,
     money: fmt(s.money),
-    createAt: s.createAt,
+    createTime: s.createTime,
+  };
+}
+
+/** 活动行 → 列表项(v1.6 C2:startAt/endAt→startTime/endTime;E6:活动级 `money` 金额字符串)。 */
+function activityRow(a: Activity): Record<string, unknown> {
+  return {
+    id: a.id,
+    institutionId: a.institutionId,
+    campusId: a.campusId,
+    type: a.type,
+    name: a.name,
+    status: a.status,
+    numberSign: a.numberSign,
+    total: a.total,
+    signed: a.signeds.filter((s) => s.status === 1).length,
+    money: fmt(a.money),
+    signable: a.signable,
+    startTime: a.startTime,
+    endTime: a.endTime,
   };
 }
 
@@ -51,21 +70,7 @@ export const activityRoutes: MockRoute[] = [
         institutionId: ctx.query.institutionId ? Number(ctx.query.institutionId) : 1,
         campusId: ctx.query.campusId ? Number(ctx.query.campusId) : 0,
       });
-      const mapped = rows.map((a) => ({
-        id: a.id,
-        institutionId: a.institutionId,
-        campusId: a.campusId,
-        type: a.type,
-        name: a.name,
-        status: a.status,
-        numberSign: a.numberSign,
-        total: a.total,
-        signed: a.signeds.filter((s) => s.status === 1).length,
-        signable: a.signable,
-        startAt: a.startAt,
-        endAt: a.endAt,
-      }));
-      return page(mapped, Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10));
+      return page(rows.map(activityRow), Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10));
     },
   },
   /* 23. POST /api/admin/activities —— 新建 */
@@ -79,7 +84,8 @@ export const activityRoutes: MockRoute[] = [
       const type = Number(ctx.body?.type ?? 0);
       if (!name || ![2, 3, 4].includes(type)) return fail(2001, 'name 缺失或 type 非法(2/3/4)');
       const newId = 100 + Math.floor(Math.random() * 900);
-      return ok({ id: newId, bizNo: `ACT${newId}` });
+      // v1.6 B5:t_courses_activity 无 biz_no 列 ⇒ 不下发 bizNo(活动是内部配置实体,无需对外单号)
+      return ok({ id: newId });
     },
   },
   /* 24. GET /api/admin/activities/:id —— 详情 */
@@ -91,7 +97,8 @@ export const activityRoutes: MockRoute[] = [
       if (denied) return denied;
       const a = findActivity(Number(ctx.params.id));
       if (!a) return fail(3001, '活动不存在');
-      return ok({ ...a, signeds: a.signeds.map(signRow) });
+      // v1.6 C2/E6:详情同 22 行形状(startTime/endTime + 活动级 money),内嵌 signeds(上限 200,超出走端点 28)
+      return ok({ ...activityRow(a), signeds: a.signeds.slice(0, 200).map(signRow) });
     },
   },
   /* 25. PUT /api/admin/activities/:id —— 更新(numberSign 不可写) */
@@ -103,7 +110,8 @@ export const activityRoutes: MockRoute[] = [
       if (denied) return denied;
       const a = findActivity(Number(ctx.params.id));
       if (!a) return fail(3001, '活动不存在');
-      if (ctx.body?.numberSign !== undefined) return fail(3140, 'numberSign 不可修改');
+      // v1.6 E5:numberSign 不可写属"传了不可写字段"(单一参数非法)⇒ 2002(3140 只收"参数互相矛盾")
+      if (ctx.body?.numberSign !== undefined) return fail(2002, 'numberSign 不可修改');
       a.name = (ctx.body?.name as string) ?? a.name;
       return ok({ id: a.id });
     },
@@ -131,7 +139,7 @@ export const activityRoutes: MockRoute[] = [
         status: Number(ctx.body?.status ?? 0),
         recommendUserId: (ctx.body?.recommendUserId as string) ?? null,
         money: toCents(String(ctx.body?.money ?? '0.00')),
-        createAt: rfc3339(Date.now()),
+        createTime: rfc3339(Date.now()),
       });
       return ok({ signedId: a.signeds[a.signeds.length - 1].id });
     },
@@ -149,9 +157,9 @@ export const activityRoutes: MockRoute[] = [
       if (!userId) return fail(2001, 'userId 缺失');
       const s = a.signeds.find((x) => x.userId === userId && x.status === 1);
       if (!s) return fail(3001, '未找到有效报名');
-      // 3107:活动已审核通过(status=1 且已结算过)不可取消
-      if (a.status === 1 && s.status === 1 && a.signed > a.total / 2) return fail(3107, '活动已审核通过,不可取消');
-      // 3108:老带新已产生消费不可取消(type=2 且 money 已核销 = money>0 且已 settle 标记)
+      // v1.6 E7:3107 判据按"报名/活动状态"判(名额报满 = 视为已结算/审核通过),不再用"报名人数过半"近似
+      if (a.signeds.filter((x) => x.status === 1).length >= a.total) return fail(3107, '活动已审核通过,不可取消');
+      // 3108:老带新已产生消费不可取消(type=2 且 money 已核销 = money>0)
       if (a.type === 2 && s.money > 0) return fail(3108, '老带新已产生消费,不可取消');
       s.status = -1;
       s.money = 0; // activityMoney/activityMoneyUsed 归零(契约 §2.2:前端刷新整行)
@@ -210,13 +218,17 @@ export const activityRoutes: MockRoute[] = [
       // mock:按用户主校区返回可用赠送池
       const u = findUser(userId);
       const campus = u?.campusId ?? 11;
+      // v1.6 B5:赠送池表无 memo 列 ⇒ items 不带 memo(不得用活动标题顶替)。
+      // usable = Σ items(同一快照);按 activityId 升序。
+      const a = 20000;
+      const b = 12000 + campus * 1000;
       return ok({
         userId,
         campusId: campus,
-        usable: fmt(32000 + campus * 1000),
+        usable: fmt(a + b),
         items: [
-          { activityId: 2, amount: fmt(20000), memo: '充值赠送' },
-          { activityId: 4, amount: fmt(12000 + campus * 1000), memo: '活动赠送' },
+          { activityId: 2, amount: fmt(a) },
+          { activityId: 4, amount: fmt(b) },
         ],
       });
     },

+ 50 - 26
src/api/mock/routes/courses.ts

@@ -1,12 +1,16 @@
 /**
  * routes/courses.ts —— 课程/排班/打卡端点 mock(academic 契约 §2.4 端点 44~56,PC 侧 13 个;端点 57 属移动端不做)。
  *
- * 契约对齐(academic-frontend-contract-v1.md v1.1 冻结):
+ * 契约对齐(academic-frontend-contract-v1.md v1.8/v1.9):
  *   - 端点 49 生成课表:同 (courses + start_time) 幂等(已生成不重复);
  *   - 端点 51 finish 重操作:一次调用产生 扣课时 + 余额变动 + 分账 + 课酬(mock 返回聚合结果 + changed 标记,
  *     前端须二次确认 + loading 锁 + 失败刷新整页——交互在页面层,mock 只保证契约返回);
- *   - 端点 52 打卡:逐学生 type/pay/cost/giveCost;
- *   - 分支:3121(打卡/上课状态非法)/ 3122(排班冲突)。
+ *   - 端点 51 `divide[]` = [{userId, type, amount}](v1.7:无 level/quota,分账日志表无层级列);
+ *   - 端点 52 打卡:逐学生 type/pay/cost/giveCost(v1.6 C3);
+ *   - 端点 46/48 无 `room`(v1.8,源列只有 address);`week` 为字符串(v1.8);
+ *   - 端点 53 `realName` 可空(v1.8);欠费字段 = `remainderReceivable`(v1.2/v1.6 A1);
+ *   - 端点 54 报名不写资金流水、不动余额(v1.8);重复报名 → 3143(v1.8);
+ *   - 分支:3121(打卡/上课状态非法)/ 3122(排班冲突)/ 3143(重复报名)。
  *
  * 本文件仅在 dev + VITE_API_MOCK 下被加载(经 http.ts 动态 import 门控),不进 dist。
  */
@@ -86,16 +90,15 @@ export const courseRoutes: MockRoute[] = [
       if (denied) return denied;
       const c = findCourse(Number(ctx.params.id));
       if (!c) return fail(3001, '课程不存在');
-      // mock:每课程 2 条排班规则
+      // v1.8:排班规则行 = {classId, courseId, teacherId, week(字符串), start, end, campusId};无 room(源列只有 address)
       const rules = c.records.slice(0, 2).map((r, i) => ({
         classId: r.id * 10 + i,
         courseId: c.id,
         teacherId: r.teacherId,
-        week: i + 1,
+        week: r.week,
         start: r.startTime,
         end: r.endTime,
         campusId: r.campusId,
-        room: r.room,
       }));
       return page(rules, Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10));
     },
@@ -125,12 +128,13 @@ export const courseRoutes: MockRoute[] = [
       if (denied) return denied;
       const c = findCourse(Number(ctx.params.id));
       if (!c) return fail(3001, '课程不存在');
+      // v1.8:课表实例行 = {recordId, courseId, teacherId, campusId, startTime, endTime, status, studentCount};
+      // 无 room(源列只有 address,地址≠教室号不得顶替)。⚠ 时间窗 `to` 含当日(闭区间,与其他端点右开相反)。
       const rows = c.records.map((r) => ({
         recordId: r.id,
         courseId: c.id,
         teacherId: r.teacherId,
         campusId: r.campusId,
-        room: r.room,
         startTime: r.startTime,
         endTime: r.endTime,
         status: r.status,
@@ -198,28 +202,33 @@ export const courseRoutes: MockRoute[] = [
       if (!rec) return fail(3001, '排班实例不存在');
       if (rec.status === 2) return fail(3121, '上课状态非法(已结束不可重复结束)');
       rec.status = 2;
-      // 逐学生:出勤者扣课时(spend 101 −),产生分账 + 课酬
+      // 逐学生:出勤(type=1)者扣课时,产生分账 + 课酬(v1.6 C3:type/pay/cost/giveCost)
       let spentTotal = 0;
       const perStudent = rec.students.map((s) => {
-        if (s.attendance === 1) {
-          appendLog(s.userId, rec.campusId, 101, -s.payCost, '课时代扣(finish)');
-          spentTotal += s.payCost;
+        if (s.type === 1) {
+          appendLog(s.userId, rec.campusId, 101, -s.cost, '课时代扣(finish)');
+          spentTotal += s.cost;
         }
         return {
           userId: s.userId,
-          attendance: s.attendance,
-          payCost: fmt(s.payCost),
+          type: s.type,
+          pay: fmt(s.pay),
+          cost: fmt(s.cost),
           giveCost: fmt(s.giveCost),
           counteracted: false,
         };
       });
+      // v1.7:divide[] = [{userId, type, amount}](分账日志表无层级列,不下发 level/quota;type=分成类型 1一次付清/2按课次)
+      const divide = rec.teacherId
+        ? [{ userId: rec.teacherId, type: 1, amount: fmt(Math.round(spentTotal * 0.3)) }]
+        : [];
       return ok({
         recordId: rec.id,
         courseId,
         status: 2,
         changed: true,
         totalSpend: fmt(spentTotal),
-        divide: [{ level: 1, quota: '0.30', amount: fmt(Math.round(spentTotal * 0.3)) }],
+        divide,
         payroll: { detailId: rec.id * 10, reward: fmt(0) },
         perStudent,
       });
@@ -239,12 +248,14 @@ export const courseRoutes: MockRoute[] = [
       }
       if (!rec) return fail(3001, '排班实例不存在');
       if (rec.status === 2) return fail(3121, '已结束不可再打卡');
-      const list = (ctx.body?.list as Array<{ userId: string; attendance: number; payCost?: string; giveCost?: string }>) ?? [];
+      // v1.6 C3:打卡请求逐学生字段 = type/pay/cost/giveCost(原 attendance/payCost 作废)
+      const list = (ctx.body?.list as Array<{ userId: string; type: number; pay?: string; cost?: string; giveCost?: string }>) ?? [];
       for (const item of list) {
         const s = rec.students.find((x) => x.userId === item.userId);
         if (s) {
-          s.attendance = item.attendance === 1 ? 1 : 0;
-          if (item.payCost) s.payCost = toCents(item.payCost);
+          s.type = item.type;
+          if (item.pay) s.pay = toCents(item.pay);
+          if (item.cost) s.cost = toCents(item.cost);
           if (item.giveCost) s.giveCost = toCents(item.giveCost);
         }
       }
@@ -261,15 +272,18 @@ export const courseRoutes: MockRoute[] = [
       const c = findCourse(Number(ctx.params.id));
       if (!c) return fail(3001, '课程不存在');
       const rec = c.records[0];
+      // v1.6 C3/A1 + v1.8:行 = {userId, realName(可空), pay, cost, giveCost, remainderReceivable, status(数字码)}。
+      // realName 查不到给 null(区分"没这个人"与"名字为空");欠费用 remainderReceivable(unpaid 是 2018 停更僵尸列)。
       const rows = (rec?.students ?? []).map((s) => {
         const u = findUser(s.userId);
         return {
           userId: s.userId,
-          realName: u?.realName ?? '—',
-          payCost: fmt(s.payCost),
+          realName: u ? u.realName : null,
+          pay: fmt(s.pay),
+          cost: fmt(s.cost),
           giveCost: fmt(s.giveCost),
-          unpaid: fmt(20000), // mock 欠费
-          status: '在读',
+          remainderReceivable: fmt(20000), // mock 欠费
+          status: 1, // 数字码(1 在读;中文前端映射)
         };
       });
       return page(rows, Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10));
@@ -284,11 +298,19 @@ export const courseRoutes: MockRoute[] = [
       if (denied) return denied;
       const c = findCourse(Number(ctx.params.id));
       if (!c) return fail(3001, '课程不存在');
+      // v1.8 补登入参:userId(必填) / pay(必填,金额) / lessonCost(必填,金额) / number(必填,课次);可选 giveNumber/discountCash/giveCost/classId/campusId/recommenderUserId/remark
       const userId = ctx.body?.userId as string;
+      const pay = toCents(String(ctx.body?.pay ?? '0.00'));
+      const lessonCost = toCents(String(ctx.body?.lessonCost ?? '0.00'));
+      const number = Number(ctx.body?.number ?? 0);
       if (!userId || !findUser(userId)) return fail(2001, 'userId 缺失或用户不存在');
+      if (pay <= 0 || lessonCost <= 0 || number <= 0) return fail(2001, 'pay/lessonCost/number 必填且须 >0');
       const rec = c.records[0];
-      if (rec && !rec.students.some((s) => s.userId === userId)) {
-        rec.students.push({ userId, attendance: 0, payCost: toCents(String(ctx.body?.payCost ?? '0.00')), giveCost: 0 });
+      // v1.8:重复报名 → 3143(专用码;3140 是"参数互相矛盾",不混用)
+      if (rec && rec.students.some((s) => s.userId === userId)) return fail(3143, '该学生已报名此课程');
+      // v1.8:⚠ 不写资金流水、不动余额——报课行只是应收/实收快照,真实收款走财务端点 3(充值)/ 4(消费)。
+      if (rec) {
+        rec.students.push({ userId, type: 0, pay, cost: lessonCost, giveCost: toCents(String(ctx.body?.giveCost ?? '0.00')) });
       }
       return ok({ enrolled: true, courseId: c.id, userId });
     },
@@ -300,10 +322,12 @@ export const courseRoutes: MockRoute[] = [
     handler: (ctx) => {
       const denied = checkScope(ctx);
       if (denied) return denied;
+      // v1.6 A1:欠费字段 = remainderReceivable(unpaid 是 2018-05 停更僵尸列,禁展示/禁返回)。
+      // v1.8:counteracted 判据 = net_receipts > 0(该行发生过实收/核销)。
       const rows = [
-        { studentId: '01J2M' + '201'.padEnd(19, '0').slice(0, 19), courseId: 1, unpaid: fmt(20000), counteracted: false },
-        { studentId: '01J2M' + '202'.padEnd(19, '0').slice(0, 19), courseId: 1, unpaid: fmt(12000), counteracted: false },
-        { studentId: '01J2M' + '204'.padEnd(19, '0').slice(0, 19), courseId: 2, unpaid: fmt(8000), counteracted: true },
+        { studentId: '01J2M' + '201'.padStart(21, '0'), courseId: 1, remainderReceivable: fmt(20000), counteracted: false },
+        { studentId: '01J2M' + '202'.padStart(21, '0'), courseId: 1, remainderReceivable: fmt(12000), counteracted: false },
+        { studentId: '01J2M' + '204'.padStart(21, '0'), courseId: 2, remainderReceivable: fmt(8000), counteracted: true },
       ];
       return page(rows, Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10));
     },

+ 14 - 11
src/api/mock/routes/divide-into.ts

@@ -7,7 +7,7 @@
  *     建完下线须显式调端点 40 设置额度,否则该链不产生分成且不报错);
  *   - 端点 40 设额度:`quota` → {affectedUserIds:[]}(含该节点及其全部下线,额度变化影响子链分成);
  *   - 端点 41/42/43 分账流水:建(findUserId,studentId,orderNo,remark?)/ 删(studentId,orderNo)/ 查(分页 + 时间范围);
- *   - 分支:3111(分账层级超限 / 额度超上限)/ 2001(缺参)/ 3001(节点/用户不存在)/ 1004(越界)。
+ *   - 分支:3111(分账层级超限 / **子额度 > 父额度**,v1.6 A2 判据更正)/ 2001(缺参)/ 3001(节点/用户不存在)/ 1004(越界)。
  *
  * 数据源:分账树来自 data.ts(getDivideTree / findDivideNode,可变引用,39 直接挂新节点);
  *   分账流水为模块级单例数组(dev 会话内持久,刷新即重置,与 mock 定位一致)。
@@ -33,12 +33,13 @@ interface DivideLog {
   orderNo: string;
   money: number; // 分
   remark: string;
-  createAt: string;
+  /** 业务时间(v1.6 C1:`createAt`→`createTime`;端点 43 行) */
+  createTime: string;
 }
 let logSeq = 7000;
 const logs: DivideLog[] = [
-  { id: ++logSeq, findUserId: '01J2M1010000000000000000', studentId: '01J2M201000000000000000', orderNo: 'ORD20260901001', money: 30000, remark: '数学课时分成', createAt: rfc3339(Date.parse('2026-11-01T09:00:00+08:00')) },
-  { id: ++logSeq, findUserId: '01J2M1020000000000000000', studentId: '01J2M203000000000000000', orderNo: 'ORD20260901002', money: 20000, remark: '物理课时分成', createAt: rfc3339(Date.parse('2026-11-02T10:30:00+08:00')) },
+  { id: ++logSeq, findUserId: '01J2M' + '101'.padStart(21, '0'), studentId: '01J2M' + '201'.padStart(21, '0'), orderNo: 'ORD20260901001', money: 30000, remark: '数学课时分成', createTime: rfc3339(Date.parse('2026-11-01T09:00:00+08:00')) },
+  { id: ++logSeq, findUserId: '01J2M' + '102'.padStart(21, '0'), studentId: '01J2M' + '203'.padStart(21, '0'), orderNo: 'ORD20260901002', money: 20000, remark: '物理课时分成', createTime: rfc3339(Date.parse('2026-11-02T10:30:00+08:00')) },
 ];
 
 function checkScope(ctx: MockCtx): R<undefined> | undefined {
@@ -129,11 +130,13 @@ export const divideIntoRoutes: MockRoute[] = [
       if (denied) return denied;
       const node = findDivideNode(ctx.params.userId);
       if (!node) return fail(3001, '分账节点不存在');
+      // v1.6 A2:quota 是**金额**(每次分成金额,元,两位小数),不是 0~1 比率;0.00 是合法值(该链不分成)
       const raw = ctx.body?.quota;
       const quota = typeof raw === 'string' ? Number(raw) : typeof raw === 'number' ? raw : NaN;
-      if (!Number.isFinite(quota)) return fail(2002, 'quota 格式错误(0~1 小数,如 "0.30")');
-      // 3111:额度超上限(>1 即 100% 以上,非法)
-      if (quota < 0 || quota > 1) return fail(3111, '分账额度超上限(quota 须 0~1)');
+      if (!Number.isFinite(quota) || quota < 0) return fail(2002, 'quota 格式错误(金额,两位小数,如 "100.00";0.00 合法)');
+      // 3111:子额度 > 父额度(级联压子链;根节点无父,不受此约束)
+      const parent = node.parentUserId ? findDivideNode(node.parentUserId) : null;
+      if (parent && quota > parent.quota) return fail(3111, `子额度不可超过父额度(父额度 ${parent.quota.toFixed(2)} 元)`);
       node.quota = quota;
       return ok({ affectedUserIds: subtreeUserIds(node) });
     },
@@ -158,7 +161,7 @@ export const divideIntoRoutes: MockRoute[] = [
         orderNo,
         money: 0,
         remark: String(ctx.body?.remark ?? ''),
-        createAt: rfc3339(Date.now()),
+        createTime: rfc3339(Date.now()),
       };
       logs.unshift(log);
       return ok({ logId: log.id });
@@ -193,12 +196,12 @@ export const divideIntoRoutes: MockRoute[] = [
         .filter((l) => {
           if (ctx.query.userId && l.findUserId !== ctx.query.userId) return false;
           if (ctx.query.studentId && l.studentId !== ctx.query.studentId) return false;
-          const t = Date.parse(l.createAt);
+          const t = Date.parse(l.createTime);
           if (from !== null && t < from) return false;
           if (to !== null && t > to) return false;
           return true;
         })
-        .sort((a, b) => (a.createAt < b.createAt ? 1 : a.createAt > b.createAt ? -1 : b.id - a.id));
+        .sort((a, b) => (a.createTime < b.createTime ? 1 : a.createTime > b.createTime ? -1 : b.id - a.id));
       const mapped = rows.map((l) => ({
         id: l.id,
         findUserId: l.findUserId,
@@ -206,7 +209,7 @@ export const divideIntoRoutes: MockRoute[] = [
         orderNo: l.orderNo,
         money: fmt(l.money),
         remark: l.remark,
-        createAt: l.createAt,
+        createTime: l.createTime,
       }));
       return page(mapped, Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10));
     },

+ 43 - 25
src/api/mock/routes/finance.ts

@@ -13,6 +13,7 @@
  */
 import { fail, ok, page } from '@/api/mock/envelope';
 import { isMoney, toCents, fmt } from '@/api/mock/money';
+import { rfc3339 } from '@/api/mock/time';
 import { inScope, findUser, getAccount, getGlobalBalance, appendLog, queryLogs, queryRequests, findRequest, createRequest, idempotentKey, takeIdempotent, setIdempotent } from '@/api/mock/data';
 import type { ExpenseLog } from '@/api/mock/data';
 import type { MockCtx, MockRoute } from '@/api/mock/adapter';
@@ -43,17 +44,19 @@ function withIdempotent(key: string, build: () => { data: Record<string, unknown
   return ok({ ...first.data, repeated: first.repeated ?? false });
 }
 
-/** 流水行 → 列表项(金额/余额字符串化)。入参:log。出参:对象。 */
+/**
+ * 流水行 → 列表项(金额/余额字符串化)。入参:log。出参:对象。
+ * v1.6 B3:`t_user_expense_log` 无机构/校区列 ⇒ 行内**不下发** institutionId/campusId(两列恒空且误导)。
+ * v1.6 C1:业务时间字段名 `createTime`(← legacy `create_time`;`createAt` 是第三种写法,作废)。
+ */
 function logRow(l: ExpenseLog): Record<string, unknown> {
   return {
     id: l.id,
     userId: l.userId,
-    institutionId: l.institutionId,
-    campusId: l.campusId,
     type: l.type,
     money: fmt(l.money),
     balanceAfter: fmt(l.balanceAfter),
-    createAt: l.createAt,
+    createTime: l.createTime,
     remark: l.remark,
   };
 }
@@ -274,12 +277,13 @@ export const financeRoutes: MockRoute[] = [
       const denied = checkScope(ctx);
       if (denied) return denied;
       const userId = ctx.body?.userId as string;
-      const campusId = Number(ctx.body?.campusId ?? 0);
+      // campusId 入参仅做作用域校验(checkScope 已读 query/body 的 institutionId/campusId),此处不再取值
       const m = moneyParam(ctx.body?.spendMoney);
       if ('err' in m) return m.err;
       if (!userId || !findUser(userId)) return fail(2001, 'userId 缺失或用户不存在');
-      const log = appendLog(userId, campusId, 101, -m.cents, 'spend 扣减');
-      return ok({ spend: fmt(m.cents), logId: log.id });
+      // v1.6 B1:旧库 sp_user_expense_school_spend 只 UPDATE spend 列、全程不写 t_user_expense_log
+      // ⇒ mock 不得伪造 101 流水(否则流水列表凭空多一条、对账恒等式被破坏);返回 { spend },无 logId。
+      return ok({ spend: fmt(m.cents) });
     },
   },
   /* 11. GET /api/admin/finance/logs —— 分页流水(倒序;分页边界主战场) */
@@ -326,7 +330,9 @@ export const financeRoutes: MockRoute[] = [
         applicantId: r.applicantId,
         approverId: r.approverId,
         remark: r.remark,
-        createAt: r.createAt,
+        // v1.6 B2:申请时间读 requestTime(业务)、审批时间读 responseTime(未审批 null);createdAt(迁移时生成列)不下发
+        requestTime: r.requestTime,
+        responseTime: r.responseTime,
       }));
       return page(mapped, Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10));
     },
@@ -359,7 +365,8 @@ export const financeRoutes: MockRoute[] = [
       if (!r) return fail(3001, '工单不存在');
       if (r.approved !== 0) return fail(3140, '工单已处理,不可重复审批');
       r.approved = 1;
-      r.approverId = '01J2M' + '302'.padEnd(19, '0').slice(0, 19);
+      r.approverId = '01J2M' + '302'.padStart(21, '0');
+      r.responseTime = rfc3339(Date.now());
       return ok({ approved: 1, result: { logId: r.id * 100, balance: fmt(0) } });
     },
   },
@@ -374,7 +381,8 @@ export const financeRoutes: MockRoute[] = [
       if (!r) return fail(3001, '工单不存在');
       if (r.approved !== 0) return fail(3140, '工单已处理,不可重复操作');
       r.approved = -1;
-      r.approverId = '01J2M' + '302'.padEnd(19, '0').slice(0, 19);
+      r.approverId = '01J2M' + '302'.padStart(21, '0');
+      r.responseTime = rfc3339(Date.now());
       return ok({ approved: -1 });
     },
   },
@@ -392,29 +400,39 @@ export const financeRoutes: MockRoute[] = [
       return ok({ approved: -2 });
     },
   },
-  /* 21. GET /api/admin/finance/dashboard —— 期间收支/余额分布(P2,简单聚合) */
+  /* 21. GET /api/admin/finance/dashboard —— 账户横截面 + 校区余额分布(v1.6 B4:删 periodIncome/periodExpense/net) */
   {
     method: 'GET',
     path: '/api/admin/finance/dashboard',
     handler: (ctx) => {
       const denied = checkScope(ctx);
       if (denied) return denied;
-      const logs = queryLogs({ from: ctx.query.from, to: ctx.query.to });
-      let income = 0;
-      let expense = 0;
-      for (const l of logs) {
-        if (l.money > 0) income += l.money;
-        else expense += -l.money;
+      // v1.6 B4:期间收支数据层给不出(流水表无机构/校区列)⇒ 不下发 periodIncome/periodExpense/net。
+      // 改下发账户维度横截面(t_user_expense / t_user_expense_school 可算)+ 校区余额分布。
+      // 口径:campusId 不生效(v1.3 rev.1),from/to 不参与(P2 端点,简单聚合)。
+      const all = queryLogs({});
+      let totalIncome = 0;
+      let totalExpenditure = 0;
+      let specialExpenses = 0;
+      for (const l of all) {
+        if (l.type === 100) totalIncome += l.money; // 充值(+)
+        else if (l.type === 108) specialExpenses += l.money; // 坏账(+,计入特殊费用)
+        else if (l.money < 0) totalExpenditure += -l.money; // 消费/退费/清零(−)
       }
+      const balance = totalIncome + specialExpenses - totalExpenditure;
+      const netReceivable = Math.max(totalExpenditure - totalIncome, 0); // 应收净额(支出超充值部分)
+      const studentByCampus: Record<number, string> = { 11: '201', 12: '203', 13: '205' };
+      const balanceDistribution = [11, 12, 13].map((campusId) => ({
+        campusId,
+        balance: fmt(getGlobalBalance('01J2M' + studentByCampus[campusId].padStart(21, '0'), campusId)),
+      }));
       return ok({
-        periodIncome: fmt(income),
-        periodExpense: fmt(expense),
-        net: fmt(income - expense),
-        balanceDistribution: [
-          { campusId: 11, balance: fmt(getGlobalBalance('01J2M' + '201'.padEnd(19, '0').slice(0, 19), 11)) },
-          { campusId: 12, balance: fmt(getGlobalBalance('01J2M' + '203'.padEnd(19, '0').slice(0, 19), 12)) },
-          { campusId: 13, balance: fmt(getGlobalBalance('01J2M' + '205'.padEnd(19, '0').slice(0, 19), 13)) },
-        ],
+        balance: fmt(balance),
+        totalIncome: fmt(totalIncome),
+        totalExpenditure: fmt(totalExpenditure),
+        specialExpenses: fmt(specialExpenses),
+        netReceivable: fmt(netReceivable),
+        balanceDistribution,
       });
     },
   },

+ 6 - 3
src/api/mock/routes/maintenance.ts

@@ -5,7 +5,7 @@
  *   - 61~63 为**维护型**数据修复端点,角色**仅 super_admin**(契约 §2.5 角色列)→ mock 侧用 requireRole
  *     校验(非 super_admin → 1003 无权限;token 失效 → 1001);
  *   - 均为**可重跑**的数据修复(幂等语义:重复执行不报错,仅返回受影响条数);
- *   - 63 activity-sync 须带 `mode=used-log|signed`(二选一,缺省/非法 → 2001)。
+ *   - 63 activity-sync 须带 `mode=used-log|signed`(二选一;**v1.9 错误码拆分**:缺失 → 2001、非法 → 2002)。
  *
  * 前端约定(契约 §2.5 注释):维护型端点入口应放"系统维护"页并加二次确认,不放在日常操作区——
  *   该交互在页面层,mock 只保证角色校验 + 参数校验 + 返回形态。
@@ -51,8 +51,11 @@ export const maintenanceRoutes: MockRoute[] = [
     handler: (ctx) => {
       const denied = requireSuper(ctx);
       if (denied) return denied;
-      const mode = ctx.body?.mode as string | undefined;
-      if (mode !== 'used-log' && mode !== 'signed') return fail(2001, 'mode 缺失或非法(须 used-log 或 signed)');
+      // v1.9 错误码拆分:mode 缺失 → 2001;mode 传了但不在白名单 → 2002(与端点 25 及全局"枚举越界=2002"一致)
+      const rawMode = ctx.body?.mode;
+      if (rawMode === undefined || rawMode === null || rawMode === '') return fail(2001, 'mode 缺失(须 used-log 或 signed)');
+      const mode = String(rawMode);
+      if (mode !== 'used-log' && mode !== 'signed') return fail(2002, 'mode 非法(仅支持 used-log 或 signed)');
       // mock:按 mode 同步 5 活动(数据源 data.ts 5 活动)
       const affected = mode === 'signed' ? 6 : 4; // signed=6 报名 / used-log=4 已用流水
       return ok({ affected, message: `已按 ${mode} 口径同步 ${affected} 条活动数据` });

+ 162 - 98
src/api/mock/routes/payroll.ts

@@ -1,60 +1,87 @@
 /**
  * routes/payroll.ts —— 课酬 / 收支端点 mock(academic 契约 §2.5 端点 58~60,3 个)。
  *
- * 契约对齐(academic-frontend-contract-v1.md v1.1 冻结):
- *   - 端点 58 课酬方案:GET/POST/PUT `/admin/payroll/plans[/:id]`(actived 启停 + effectDate 生效日;
- *     teacherExp/percentage/min/max/reward 为方案参数,金额恒字符串两位小数,percentage 为 0~1 小数比);
- *   - 端点 59 课酬测算:POST `/admin/payroll/quote`(teacherId,schoolId,campusId,classifyId,teacherExp,date)
- *     → {detailId,percentage,min,max,reward};**3112(课酬方案未配置)→ 引导去配置**;
- *   - 端点 60 收支:GET `/admin/expenditures` 收支记录(分页)/ GET `/admin/expenditures/types` 类型字典 /
- *     POST `/admin/expenditures` 新增收支记录(type 须来自字典,direction 定收支方向)。
+ * 契约对齐(academic-frontend-contract-v1.md v1.9):
+ *   - 端点 58 课酬方案(**两级结构**):GET/POST/PUT `/admin/payroll/plans[/:id]`
+ *     → 方案行 = {id, institutionId, campusId, schemeName, effectDate, actived, details:[…]};
+ *     明细 = {detailId, teacherId(可空), classifyId, classType, gradeId, subjectId, teacherCert,
+ *             teacherExpMin, teacherExpMax, percentage(百分数字符串), min/max/reward(金额), displayName, detailActived}。
+ *     `actived` = 方案级、`detailActived` = 明细级;教龄是区间(两字段);
+ *     `PUT /plans/:id` 的 :id = **方案 id**,提交完整 details → 整体替换(同事务先删后插)。
+ *   - 端点 59 课酬试算:POST `/admin/payroll/quote`(teacherId,schoolId,campusId,classifyId,teacherExp,date)
+ *     → **9 字段** {detailId, percentage(百分数), min, max, reward, schemeId, schemeCampusId, schemeScope, ruleTier};
+ *     **只返回规则参数、不含最终金额**;3112(方案未配置)→ 引导去配置。
+ *   - 端点 60 收支:GET `/admin/expenditures` 记录(行 = {id, expenditureId, type(1支出/2收入), name, money,
+ *     createUserId(可空), campusId, bizNo, createTime},**删 direction**);GET `/admin/expenditures/types`
+ *     → types:[{id, type, name, parentId}];POST 记录 → {id, bizNo}。
  *
- * 数据源:课酬方案 / 收支类型字典均来自 data.ts(queryPayrollPlans / findPayrollPlan / expenditureTypes /
- *   expenditureTypeOf);收支记录为模块级单例数组。
+ * 数据源:课酬方案 / 收支类型字典均来自 data.ts(queryPayrollPlans / expenditureTypes /
+ *   expenditureTypeOf);方案与收支记录为模块级单例(可变副本)。
  *
  * 本文件仅在 dev + VITE_API_MOCK 下被加载(经 http.ts 动态 import 门控),不进 dist。
  */
 import { fail, ok, page } from '@/api/mock/envelope';
 import { fmt, toCents } from '@/api/mock/money';
 import { rfc3339 } from '@/api/mock/time';
-import { inScope, findUser, queryPayrollPlans, findPayrollPlan, expenditureTypes, expenditureTypeOf } from '@/api/mock/data';
+import { inScope, findUser, queryPayrollPlans, expenditureTypes, expenditureTypeOf } from '@/api/mock/data';
+import type { PayrollDetail, PayrollPlan } from '@/api/mock/data';
 import type { MockCtx, MockRoute } from '@/api/mock/adapter';
 import type { R } from '@/types/api';
 
-/** 课酬方案行 → 输出(金额字符串、percentage 保留 0~1 小数)。入参:plan。出参:对象。 */
-interface PlanSeed {
-  id: number;
-  teacherId: string;
-  schoolId: number;
-  campusId: number;
-  classifyId: number;
-  teacherExp: number;
-  percentage: number;
-  min: number;
-  max: number;
-  reward: number;
-  actived: 0 | 1;
-  effectDate: string;
+/** 模块级课酬方案(两级;data.ts 提供只读初值,本文件深拷贝为可变副本以支持 POST/PUT 整体替换)。 */
+const plans: PayrollPlan[] = queryPayrollPlans({}).map((p) => ({
+  ...p,
+  details: p.details.map((d) => ({ ...d })),
+}));
+
+/** 课酬方案行 → 输出(v1.9 两级结构;金额恒字符串两位小数,percentage 为百分数字符串如 "85.0")。 */
+function planRow(p: PayrollPlan): Record<string, unknown> {
+  return {
+    id: p.id,
+    institutionId: p.institutionId,
+    campusId: p.campusId,
+    schemeName: p.schemeName,
+    effectDate: p.effectDate,
+    actived: p.actived,
+    details: p.details.map((d) => ({
+      detailId: d.detailId,
+      teacherId: 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.toFixed(1),
+      min: fmt(d.min),
+      max: fmt(d.max),
+      reward: fmt(d.reward),
+      displayName: d.displayName,
+      detailActived: d.detailActived,
+    })),
+  };
 }
-/** 模块级课酬方案种子(data.ts 提供只读初值;本文件做可变副本以支持 POST/PUT)。 */
-const plans: PlanSeed[] = queryPayrollPlans({}).map((p) => ({ ...p }));
 
-/** 收支记录(模块级单例;dev 会话内可变)。 */
+/** 收支记录(模块级单例;dev 会话内可变)。v1.9:删 direction、userId→createUserId、补 expenditureId。 */
 interface Expenditure {
   id: number;
+  expenditureId: number;
+  /** 1 支出 / 2 收入(与 type 同事实;两表无 direction 列) */
   type: number;
   name: string;
-  direction: 1 | -1;
   money: number; // 分
-  userId: string;
+  /** 记账人(← create_user_id;记录表无"收付对象"列) */
+  createUserId: string | null;
   campusId: number;
   bizNo: string;
-  createAt: string;
+  /** 业务时间(v1.6 C1:createAt→createTime) */
+  createTime: string;
 }
 let expSeq = 8000;
 const expenditures: Expenditure[] = [
-  { id: ++expSeq, type: 101, name: '课程消费(校区)', direction: -1, money: 10000, userId: '01J2M201000000000000000', campusId: 11, bizNo: 'EXP20261101001', createAt: rfc3339(Date.parse('2026-11-01T09:00:00+08:00')) },
-  { id: ++expSeq, type: 300, name: '线下收入', direction: 1, money: 50000, userId: '01J2M201000000000000000', campusId: 11, bizNo: 'EXP20261102002', createAt: rfc3339(Date.parse('2026-11-02T11:00:00+08:00')) },
+  { id: ++expSeq, expenditureId: 2, type: 1, name: '课程消费(校区)', money: 10000, createUserId: '01J2M' + '201'.padStart(21, '0'), campusId: 11, bizNo: 'EXP20261101001', createTime: rfc3339(Date.parse('2026-11-01T09:00:00+08:00')) },
+  { id: ++expSeq, expenditureId: 4, type: 2, name: '线下收入', money: 50000, createUserId: '01J2M' + '201'.padStart(21, '0'), campusId: 11, bizNo: 'EXP20261102002', createTime: rfc3339(Date.parse('2026-11-02T11:00:00+08:00')) },
 ];
 
 function checkScope(ctx: MockCtx): R<undefined> | undefined {
@@ -63,21 +90,29 @@ function checkScope(ctx: MockCtx): R<undefined> | undefined {
   return inScope(institutionId, campusId) ? undefined : fail(1004, '作用域越界:当前账号无权访问该机构/校区');
 }
 
-function planRow(p: PlanSeed): Record<string, unknown> {
-  return {
-    id: p.id,
-    teacherId: p.teacherId,
-    schoolId: p.schoolId,
-    campusId: p.campusId,
-    classifyId: p.classifyId,
-    teacherExp: p.teacherExp,
-    percentage: p.percentage,
-    min: fmt(p.min),
-    max: fmt(p.max),
-    reward: fmt(p.reward),
-    actived: p.actived,
-    effectDate: p.effectDate,
-  };
+/** 解析请求体中的明细数组 → PayrollDetail[](金额转分、percentage 转百分比数、actived 归 0/1)。 */
+function parseDetails(raw: unknown, startId: number): PayrollDetail[] {
+  const arr = Array.isArray(raw) ? raw : [];
+  return arr.map((d, i) => {
+    const t = d as Record<string, unknown>;
+    return {
+      detailId: Number(t.detailId ?? startId + i + 1),
+      teacherId: typeof t.teacherId === 'string' && t.teacherId ? t.teacherId : null,
+      classifyId: Number(t.classifyId ?? 0),
+      classType: Number(t.classType ?? 1),
+      gradeId: Number(t.gradeId ?? -1),
+      subjectId: Number(t.subjectId ?? -1),
+      teacherCert: Number(t.teacherCert ?? 0),
+      teacherExpMin: Number(t.teacherExpMin ?? 0),
+      teacherExpMax: Number(t.teacherExpMax ?? 0),
+      percentage: Number(t.percentage ?? 0),
+      min: toCents(String(t.min ?? '0.00')),
+      max: toCents(String(t.max ?? '0.00')),
+      reward: toCents(String(t.reward ?? '0.00')),
+      displayName: typeof t.displayName === 'string' ? t.displayName : null,
+      detailActived: Number(t.detailActived ?? 0) === 1 ? 1 : 0,
+    };
+  });
 }
 
 export const payrollRoutes: MockRoute[] = [
@@ -90,54 +125,59 @@ export const payrollRoutes: MockRoute[] = [
       if (denied) return denied;
       let rows = plans;
       if (ctx.query.actived !== undefined) rows = rows.filter((p) => p.actived === Number(ctx.query.actived));
-      if (ctx.query.classifyId !== undefined) rows = rows.filter((p) => p.classifyId === Number(ctx.query.classifyId));
-      if (ctx.query.teacherId) rows = rows.filter((p) => p.teacherId === ctx.query.teacherId);
+      // v1.9 两级结构:classifyId/teacherId 不再在方案顶层,下沉到明细匹配(命中任一明细即保留方案)
+      if (ctx.query.classifyId !== undefined) {
+        const classifyId = Number(ctx.query.classifyId);
+        rows = rows.filter((p) => p.details.some((d) => d.classifyId === classifyId));
+      }
+      if (ctx.query.teacherId) rows = rows.filter((p) => p.details.some((d) => d.teacherId === ctx.query.teacherId));
       return page(rows.map(planRow), Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10));
     },
   },
-  /* 58b. POST /api/admin/payroll/plans —— 新建课酬方案 */
+  /* 58b. POST /api/admin/payroll/plans —— 新建课酬方案(v1.9:方案 + 明细两级,明细随建随给) */
   {
     method: 'POST',
     path: '/api/admin/payroll/plans',
     handler: (ctx) => {
       const denied = checkScope(ctx);
       if (denied) return denied;
-      const teacherId = ctx.body?.teacherId as string | undefined;
-      if (!teacherId || !findUser(teacherId)) return fail(2001, 'teacherId 缺失或用户不存在');
-      const p: PlanSeed = {
-        id: Date.now(),
-        teacherId,
-        schoolId: Number(ctx.body?.schoolId ?? 1),
-        campusId: Number(ctx.body?.campusId ?? 0),
-        classifyId: Number(ctx.body?.classifyId ?? 0),
-        teacherExp: Number(ctx.body?.teacherExp ?? 0),
-        percentage: Number(ctx.body?.percentage ?? 0),
-        min: toCents(String(ctx.body?.min ?? '0.00')),
-        max: toCents(String(ctx.body?.max ?? '0.00')),
-        reward: toCents(String(ctx.body?.reward ?? '0.00')),
-        actived: Number(ctx.body?.actived ?? 0) === 1 ? 1 : 0,
+      const schemeName = ctx.body?.schemeName as string | undefined;
+      if (!schemeName) return fail(2001, 'schemeName 缺失');
+      const details = parseDetails(ctx.body?.details, 0);
+      if (details.length === 0) return fail(2001, 'details 缺失或为空');
+      const p: PayrollPlan = {
+        id: plans.length > 0 ? Math.max(...plans.map((x) => x.id)) + 1 : 1,
+        institutionId: Number(ctx.body?.institutionId ?? 1),
+        campusId: Number(ctx.body?.campusId ?? 0), // 0 = 机构级方案
+        schemeName,
         effectDate: String(ctx.body?.effectDate ?? '2026-09-01'),
+        actived: Number(ctx.body?.actived ?? 0) === 1 ? 1 : 0,
+        details,
       };
       plans.push(p);
       return ok({ id: p.id });
     },
   },
-  /* 58c. PUT /api/admin/payroll/plans/:id —— 更新课酬方案(actived 启停 / effectDate 生效日) */
+  /* 58c. PUT /api/admin/payroll/plans/:id —— 更新课酬方案(v1.9::id = 方案 id;提交完整 details → 整体替换) */
   {
     method: 'PUT',
     path: '/api/admin/payroll/plans/:id',
     handler: (ctx) => {
       const denied = checkScope(ctx);
       if (denied) return denied;
-      const p = plans.find((x) => x.id === Number(ctx.params.id)) ?? findPayrollPlan(Number(ctx.params.id));
+      const p = plans.find((x) => x.id === Number(ctx.params.id));
       if (!p) return fail(3001, '课酬方案不存在');
-      if (ctx.body?.actived !== undefined) (p as PlanSeed).actived = Number(ctx.body.actived) === 1 ? 1 : 0;
-      if (ctx.body?.effectDate !== undefined) (p as PlanSeed).effectDate = String(ctx.body.effectDate);
-      if (ctx.body?.percentage !== undefined) (p as PlanSeed).percentage = Number(ctx.body.percentage);
+      // 顶层字段按需更新
+      if (typeof ctx.body?.schemeName === 'string' && ctx.body.schemeName) p.schemeName = String(ctx.body.schemeName);
+      if (ctx.body?.effectDate !== undefined) p.effectDate = String(ctx.body.effectDate);
+      if (ctx.body?.actived !== undefined) p.actived = Number(ctx.body.actived) === 1 ? 1 : 0;
+      if (ctx.body?.campusId !== undefined) p.campusId = Number(ctx.body.campusId);
+      // 明细整体替换(同事务先删后插的 mock 等价:直接换数组);未提交 → 保持原样
+      if (ctx.body?.details !== undefined) p.details = parseDetails(ctx.body.details, p.id * 100);
       return ok({ id: p.id });
     },
   },
-  /* 59. POST /api/admin/payroll/quote —— 课酬测算(3112:方案未配置) */
+  /* 59. POST /api/admin/payroll/quote —— 课酬试算(v1.9:9 字段,只回规则参数,不含最终金额;3112 = 方案未配置) */
   {
     method: 'POST',
     path: '/api/admin/payroll/quote',
@@ -145,21 +185,42 @@ export const payrollRoutes: MockRoute[] = [
       const denied = checkScope(ctx);
       if (denied) return denied;
       const teacherId = ctx.body?.teacherId as string | undefined;
+      const campusId = Number(ctx.body?.campusId ?? 0);
       const classifyId = Number(ctx.body?.classifyId ?? 0);
+      const teacherExp = Number(ctx.body?.teacherExp ?? 0);
       if (!teacherId || !findUser(teacherId) || classifyId <= 0) return fail(2001, 'teacherId/classifyId 缺失或非法');
-      const plan = plans.find((p) => p.teacherId === teacherId && p.classifyId === classifyId && p.actived === 1);
-      // 3112:无生效方案 → 引导去配置
-      if (!plan) return fail(3112, '课酬方案未配置,请先配置该老师该科目的生效方案');
+      // 匹配顺序(R9 定序):校区级方案优先、机构级兜底;方案 id DESC、明细 id ASC;
+      // 明细须 detailActived=1、classifyId 命中、teacherId 本人或通用档(null)、教龄落在 [min,max]
+      let hit: { plan: PayrollPlan; d: PayrollDetail } | undefined;
+      for (const scope of [campusId, 0]) {
+        for (const p of plans.filter((x) => x.actived === 1 && x.campusId === scope).sort((a, b) => b.id - a.id)) {
+          for (const d of [...p.details].sort((a, b) => a.detailId - b.detailId)) {
+            if (d.detailActived !== 1 || d.classifyId !== classifyId) continue;
+            if (d.teacherId !== null && d.teacherId !== teacherId) continue;
+            if (teacherExp < d.teacherExpMin || teacherExp > d.teacherExpMax) continue;
+            hit = { plan: p, d };
+            break;
+          }
+          if (hit) break;
+        }
+        if (hit) break;
+      }
+      if (!hit) return fail(3112, '课酬方案未配置,请先配置该老师该科目的生效方案');
+      // mock 简化:入参无 grade/subject/cert,仅能区分「个人档 / 无证通用档」两档(personal_classify / nocert_general)
       return ok({
-        detailId: Date.now(),
-        percentage: plan.percentage,
-        min: fmt(plan.min),
-        max: fmt(plan.max),
-        reward: fmt(plan.reward),
+        detailId: hit.d.detailId,
+        percentage: hit.d.percentage.toFixed(1),
+        min: fmt(hit.d.min),
+        max: fmt(hit.d.max),
+        reward: fmt(hit.d.reward),
+        schemeId: hit.plan.id,
+        schemeCampusId: hit.plan.campusId,
+        schemeScope: hit.plan.campusId === 0 ? 'institution' : 'campus',
+        ruleTier: hit.d.teacherId === teacherId ? 'personal_classify' : 'nocert_general',
       });
     },
   },
-  /* 60a. GET /api/admin/expenditures —— 收支记录(分页 + type/direction 筛选) */
+  /* 60a. GET /api/admin/expenditures —— 收支记录(v1.9:删 direction,type/createUserId/createTime 形状) */
   {
     method: 'GET',
     path: '/api/admin/expenditures',
@@ -167,60 +228,63 @@ export const payrollRoutes: MockRoute[] = [
       const denied = checkScope(ctx);
       if (denied) return denied;
       let rows = expenditures;
-      if (ctx.query.type !== undefined) rows = rows.filter((e) => e.type === Number(ctx.query.type));
-      if (ctx.query.direction !== undefined) rows = rows.filter((e) => e.direction === Number(ctx.query.direction));
-      if (ctx.query.userId) rows = rows.filter((e) => e.userId === ctx.query.userId);
+      if (ctx.query.type !== undefined) rows = rows.filter((e) => e.type === Number(ctx.query.type)); // 1 支出 / 2 收入
       const mapped = rows
         .slice()
-        .sort((a, b) => (a.createAt < b.createAt ? 1 : -1))
+        .sort((a, b) => (a.createTime < b.createTime ? 1 : -1))
         .map((e) => ({
           id: e.id,
+          expenditureId: e.expenditureId,
           type: e.type,
           name: e.name,
-          direction: e.direction,
           money: fmt(e.money),
-          userId: e.userId,
+          createUserId: e.createUserId,
           campusId: e.campusId,
           bizNo: e.bizNo,
-          createAt: e.createAt,
+          createTime: e.createTime,
         }));
       return page(mapped, Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10));
     },
   },
-  /* 60b. GET /api/admin/expenditures/types —— 收支类型字典 */
+  /* 60b. GET /api/admin/expenditures/types —— 收支类型字典(v1.9:{id, type, name, parentId};POST /types 本轮不做) */
   {
     method: 'GET',
     path: '/api/admin/expenditures/types',
     handler: (ctx) => {
       const denied = checkScope(ctx);
       if (denied) return denied;
-      return ok({ types: expenditureTypes.map((t) => ({ type: t.type, name: t.name, direction: t.direction })) });
+      return ok({ types: expenditureTypes.map((t) => ({ id: t.id, type: t.type, name: t.name, parentId: t.parentId })) });
     },
   },
-  /* 60c. POST /api/admin/expenditures —— 新增收支记录 */
+  /* 60c. POST /api/admin/expenditures —— 新增收支记录(v1.9:expenditureId 回填字典;createUserId 可空) */
   {
     method: 'POST',
     path: '/api/admin/expenditures',
     handler: (ctx) => {
       const denied = checkScope(ctx);
       if (denied) return denied;
-      const type = Number(ctx.body?.type ?? 0);
-      const dict = expenditureTypeOf(type);
-      if (!dict) return fail(2001, 'type 缺失或不在收支字典内');
+      const expenditureId = Number(ctx.body?.expenditureId ?? 0);
+      const dict = expenditureTypeOf(expenditureId);
+      if (!dict) return fail(2001, 'expenditureId 缺失或不在收支字典内');
       const money = toCents(String(ctx.body?.money ?? '0.00'));
       if (money <= 0) return fail(2002, '金额须大于 0');
-      const userId = ctx.body?.userId as string | undefined;
-      if (!userId || !findUser(userId)) return fail(2001, 'userId 缺失或用户不存在');
+      // createUserId = 记账人(可空);传了则校验存在
+      const rawCreateUserId = ctx.body?.createUserId;
+      let createUserId: string | null = null;
+      if (typeof rawCreateUserId === 'string' && rawCreateUserId) {
+        if (!findUser(rawCreateUserId)) return fail(2001, 'createUserId 用户不存在');
+        createUserId = rawCreateUserId;
+      }
       const e: Expenditure = {
         id: ++expSeq,
-        type,
+        expenditureId: dict.id,
+        type: dict.type, // type/name 由字典 JOIN 得出(记录表无这两列)
         name: dict.name,
-        direction: dict.direction,
         money,
-        userId,
+        createUserId,
         campusId: Number(ctx.body?.campusId ?? 0),
         bizNo: `EXP${expSeq}`,
-        createAt: rfc3339(Date.now()),
+        createTime: rfc3339(Date.now()),
       };
       expenditures.unshift(e);
       return ok({ id: e.id, bizNo: e.bizNo });

+ 2 - 2
src/views/academic/activity/index.vue

@@ -25,8 +25,8 @@ const columns: ListColumn<ActivityDTO>[] = [
   { prop: 'type', label: '类型', minWidth: 90, formatter: (r) => typeText[r.type] ?? String(r.type) },
   { prop: 'status', label: '状态', minWidth: 90, formatter: (r) => statusText[r.status] ?? String(r.status) },
   { prop: 'signed', label: '报名', minWidth: 80, formatter: (r) => `${r.signed}/${r.total}` },
-  { prop: 'startAt', label: '开始', minWidth: 140, formatter: (r) => formatDateTime(r.startAt) },
-  { prop: 'endAt', label: '结束', minWidth: 140, formatter: (r) => formatDateTime(r.endAt) },
+  { prop: 'startTime', label: '开始', minWidth: 140, formatter: (r) => formatDateTime(r.startTime) },
+  { prop: 'endTime', label: '结束', minWidth: 140, formatter: (r) => formatDateTime(r.endTime) },
 ];
 
 function onSearch(): void {

+ 2 - 2
src/views/academic/fee/index.vue

@@ -93,7 +93,7 @@ const logColumns: ListColumn<ExpenseLogDTO>[] = [
   { prop: 'money', label: '金额', minWidth: 110, formatter: (r) => `${r.money}` },
   { prop: 'balanceAfter', label: '余额(后)', minWidth: 110 },
   { prop: 'remark', label: '备注', minWidth: 140 },
-  { prop: 'createAt', label: '时间', minWidth: 150, formatter: (r) => formatDateTime(r.createAt) },
+  { prop: 'createTime', label: '时间', minWidth: 150, formatter: (r) => formatDateTime(r.createTime) },
 ];
 function onLogSearch(): void {
   logPage.value = 1;
@@ -187,7 +187,7 @@ const reqColumns: ListColumn<BalanceRequestDTO>[] = [
   { prop: 'money', label: '金额', minWidth: 100 },
   { prop: 'approved', label: '状态', minWidth: 90, formatter: (r) => reqStatusText[r.approved] ?? String(r.approved) },
   { prop: 'remark', label: '备注', minWidth: 120 },
-  { prop: 'createAt', label: '申请时间', minWidth: 140, formatter: (r) => formatDateTime(r.createAt) },
+  { prop: 'requestTime', label: '申请时间', minWidth: 140, formatter: (r) => formatDateTime(r.requestTime) },
 ];
 function onReqSearch(): void {
   reqPage.value = 1;

+ 13 - 5
vite.config.ts

@@ -1,7 +1,7 @@
 import { fileURLToPath, URL } from 'node:url';
 
 import vue from '@vitejs/plugin-vue';
-import { defineConfig } from 'vite';
+import { defineConfig, loadEnv } from 'vite';
 
 /**
  * 框架运行时全走 UMD 全局(README §3.3-1「禁 import 框架」)的构建侧落地:
@@ -85,7 +85,11 @@ for (const api of VUE_APIS) defineMap[api] = `Vue.${api}`;
 for (const api of VUE_ROUTER_APIS) defineMap[api] = `VueRouter.${api}`;
 for (const api of PINIA_APIS) defineMap[api] = `Pinia.${api}`;
 
-export default defineConfig(({ command }) => ({
+export default defineConfig(({ command, mode }) => {
+  // 契约 §4 四层口径(v0.1.24 定稿):所有对外请求 = /api + 相对路径,无一例外;
+  // 前端调用路径自带 /api,vite rewrite 无条件去前缀,后端路由一律不含 /api。
+  const env = loadEnv(mode, process.cwd(), 'VITE_');
+  return {
   plugins: [vue()],
   /**
    * dev(command === 'serve'):本地把 vendor/ 当 public 根,index.html 的相对路径
@@ -108,10 +112,13 @@ export default defineConfig(({ command }) => ({
   server: {
     port: 5173,
     proxy: {
-      // dev 联调:/api 前缀请求代理到后端 dev 地址(可用环境变量覆盖)
+      // dev 联调:/api 前缀请求代理到后端 dev 地址(.env / .env.development 的 VITE_API_PROXY_TARGET 可覆盖;
+      // loadEnv 显式读 .env,不依赖 shell env——shell 里 export 对 vite 进程不可见)
       '/api': {
-        target: process.env.VITE_API_PROXY_TARGET ?? 'http://127.0.0.1:8080',
+        target: env.VITE_API_PROXY_TARGET ?? 'http://127.0.0.1:8080',
         changeOrigin: true,
+        // 契约 §4:去 /api 前缀(无条件、无例外——含 /api/pay/...,支付渠道回调地址由 nginx 侧配置)
+        rewrite: (p) => p.replace(/^\/api/, ''),
       },
     },
   },
@@ -119,4 +126,5 @@ export default defineConfig(({ command }) => ({
     // 质量门禁:主 chunk 上限(README §3.6);框架误打包时体积会显著超限
     chunkSizeWarningLimit: 1024,
   },
-}));
+  };
+});