/** * data.ts —— mock 数据源(academic-mock-plan §6 数据策略 + §4 行为契约)。 * * 定位:dev 会话内单例内存数据(刷新即重置,不落 localStorage)。承载: * - 固定作用域实体(3 校区 / 10 用户,与原型一致); * - 账户余额(全局 + 校区,分整数); * - 资金流水(每用户 320 条 → 覆盖分页边界:total>200 / pageSize 上限 200 / 越界页空集); * - 活动 / 课程排班 / 分账树 / 课酬方案 / 资金工单(工单驱动资金操作); * - 幂等存储(同 requestId 重复提交 → repeated=true)+ 余额/流水的可变状态(充值/退费/清零/转结改余额并追加流水)。 * * 契约对齐(academic-frontend-contract-v1.md,冻结): * - 流水 type 编码 = §10.2 编码表(100 充值 + / 101,201 消费 − / 207 退费 − / 208 清零 − / 106,206 转结 等); * - 金额恒**分**整数存、对外输出**字符串两位小数**(money.ts); * - 时间恒 RFC3339 +08:00(time.ts);用户 ID 字符串、业务主键 int64、单号 bizNo 字符串。 * * 本文件仅在 dev + VITE_API_MOCK 下被加载(http.ts 动态 import 门控),不进 dist。 */ import { fmt, toCents } from '@/api/mock/money'; import { rfc3339 } from '@/api/mock/time'; /* ================= 种子随机(确定性,保证每次会话数据一致) ================= */ function mulberry32(seed: number): () => number { let a = seed | 0; return () => { a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } const rand = mulberry32(20261109); const randInt = (min: number, max: number): number => Math.floor(rand() * (max - min + 1)) + min; const pick = (arr: readonly T[]): T => arr[Math.floor(rand() * arr.length)]; /** 基准时刻:2026-11-09T12:00:00+08:00(数据在其前 60 天分布,确定性) */ const BASE_MS = Date.parse('2026-11-09T12:00:00+08:00'); /* ================= 作用域实体 ================= */ export interface MockCampus { campusId: number; institutionId: number; name: string; } export interface MockUser { /** canonical_user_id(ULID 26 位字符串,契约 §1.5) */ userId: string; realName: string; personType: string; /** 脱敏手机号 */ phone: string; /** 归属校区(mock 简化:每用户主校区) */ campusId: number; role: string; } export const campuses: MockCampus[] = [ { campusId: 11, institutionId: 1, name: '滨江校区' }, { campusId: 12, institutionId: 1, name: '城西校区' }, { campusId: 13, institutionId: 1, name: '高新校区' }, ]; // 契约 §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' }, { userId: ulid(102), realName: '李强', personType: 'staff', phone: '135****4477', campusId: 12, role: 'teacher' }, { userId: ulid(201), realName: '王梓涵', personType: 'student', phone: '139****2211', campusId: 11, role: 'student' }, { userId: ulid(202), realName: '陈一诺', personType: 'student', phone: '137****6644', campusId: 11, role: 'student' }, { userId: ulid(203), realName: '李沐宸', personType: 'student', phone: '138****9900', campusId: 12, role: 'student' }, { userId: ulid(204), realName: '赵雨桐', personType: 'student', phone: '136****5511', campusId: 11, role: 'student' }, { userId: ulid(205), realName: '刘子墨', personType: 'student', phone: '133****7788', campusId: 12, role: 'student' }, { userId: ulid(301), realName: '周敏', personType: 'staff', phone: '132****8899', campusId: 11, role: 'academic' }, { userId: ulid(302), realName: '财务管理员', personType: 'staff', phone: '131****6633', campusId: 11, role: 'finance' }, { userId: ulid(303), realName: '超级管理员', personType: 'staff', phone: '130****1122', campusId: 11, role: 'super_admin' }, ]; /** 合法作用域校验(mock 侧 1004 越界判定:campusId 不在集合或 institutionId 不在 {1,0} → 越界)。 */ export function inScope(institutionId: number, campusId: number | undefined | null): boolean { if (institutionId !== 1 && institutionId !== 0) return false; if (campusId === undefined || campusId === null || campusId === 0) return true; return campuses.some((c) => c.campusId === campusId); } export function findUser(userId: string): MockUser | undefined { return users.find((u) => u.userId === userId); } export function findCampus(campusId: number): MockCampus | undefined { return campuses.find((c) => c.campusId === campusId); } /* ================= 账户余额(分整数;全局 + 校区) ================= */ interface Account { totalBalance: number; campusBalance: number; } const accounts = new Map(); // key = `${userId}@${campusId}` function accountKey(userId: string, campusId: number): string { return `${userId}@${campusId}`; } function ensureAccount(userId: string, campusId: number, total: number, campus: number): Account { const k = accountKey(userId, campusId); let a = accounts.get(k); if (!a) { a = { totalBalance: total, campusBalance: campus }; accounts.set(k, a); } return a; } function getUserTotal(userId: string): number { let sum = 0; for (const c of campuses) sum += accounts.get(accountKey(userId, c.campusId))?.totalBalance ?? 0; return sum; } /** 初始化账户:学生/老师有正余额(可退/可清零),1 名 0 余额(3102 触发),1 名负余额。 */ (function seedAccounts() { const students = users.filter((u) => u.personType === 'student'); students.forEach((u, i) => { // 王梓涵(201) 滨江:正余额;陈一诺(202) 滨江:0 余额(3102);其余正 const campusTotal = i === 1 ? 0 : randInt(800, 4000) * 100; // 800~4000 元 ensureAccount(u.userId, u.campusId, campusTotal, i === 1 ? 0 : Math.floor(campusTotal * 0.6)); // 部分学生在另一校区也有账户 if (i % 2 === 0) { const other = campuses.find((c) => c.campusId !== u.campusId)!; ensureAccount(u.userId, other.campusId, Math.floor(campusTotal * 0.4), 0); } }); // 李沐宸(203) 额外给一个负余额校区(3120 清零前置:校区余额>全局 或 余额≤0 场景) ensureAccount(ulid(203), 13, -5000, 30000); })(); export function getAccount(userId: string, campusId: number): { total: number; campus: number } { const a = accounts.get(accountKey(userId, campusId)); return { total: a?.totalBalance ?? 0, campus: a?.campusBalance ?? 0 }; } /** 当前作用域内的全局余额(campusId=0 表示机构级聚合)。 */ export function getGlobalBalance(userId: string, campusId: number): number { if (campusId && campusId > 0) return getAccount(userId, campusId).total; return getUserTotal(userId); } /* ================= 资金流水(t_user_expense_log 形态) ================= */ export interface ExpenseLog { id: number; userId: string; institutionId: number; campusId: number; /** 流水 type(§10.2 编码表) */ type: number; /** 金额(分,含方向符号:充值/坏账 +,消费/退费/清零 −) */ money: number; balanceAfter: number; /** 业务时间(契约 C1:`createAt` 是第三种写法,正确名为 `createTime` ← legacy `create_time`) */ createTime: string; remark: string; requestId?: string; } const LOG_TYPE_REMARK: Record = { 100: '充值', 101: '课程消费(校区)', 201: '课程消费(校区)', 104: '课程消费(学校)', 204: '课程消费(学校)', 207: '余额退费', 208: '余额清零', 106: '转结出', 206: '转结入', }; const SEED_TYPES = [100, 101, 101, 201, 104, 207, 208] as const; const PER_USER_LOGS = 320; // > 200,覆盖分页边界 let logSeq = 90000; const logs: ExpenseLog[] = []; (function seedLogs() { for (const u of users) { if (u.personType !== 'student') continue; // 仅学生造大量流水 let running = getAccount(u.userId, u.campusId).total; for (let i = 0; i < PER_USER_LOGS; i++) { const type = pick(SEED_TYPES); // 金额 20~3000 元;充值/转结入 为正,其余为负 const yuan = randInt(20, 3000); // 仅 100(充值)为正;其余消费/退费/清零为负(106/206 转结不入 SEED_TYPES,故不判) const positive = type === 100; const money = positive ? yuan * 100 : -yuan * 100; running = Math.max(running + money, -200000); const createTime = rfc3339(BASE_MS - i * 3600_000 * randInt(1, 30)); logs.push({ id: ++logSeq, userId: u.userId, institutionId: 1, campusId: u.campusId, type, money, balanceAfter: running, createTime, remark: LOG_TYPE_REMARK[type] ?? '流水', }); } } })(); /** 按筛选条件取**完整**流水集(倒序;分页由 envelope.page 负责)。 */ export interface LogFilter { userId?: string; type?: number; from?: string; to?: string; institutionId?: number; campusId?: number; } export function queryLogs(f: LogFilter): ExpenseLog[] { const from = f.from ? Date.parse(`${f.from}T00:00:00+08:00`) : null; const to = f.to ? Date.parse(`${f.to}T23:59:59.999+08:00`) : null; return logs .filter((l) => { if (f.userId && l.userId !== f.userId) return false; 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.createTime); if (from !== null && t < from) return false; if (to !== null && t > to) return false; return true; }) .sort((a, b) => (a.createTime < b.createTime ? 1 : a.createTime > b.createTime ? -1 : b.id - a.id)); } /** 追加一条流水(写端点共用;返回新 logId)。 */ export function appendLog(userId: string, campusId: number, type: number, money: number, remark: string, requestId?: string): ExpenseLog { const a = ensureAccount(userId, campusId, 0, 0); a.campusBalance += money; a.totalBalance += money; const log: ExpenseLog = { id: ++logSeq, userId, institutionId: 1, campusId, type, money, balanceAfter: a.totalBalance, createTime: rfc3339(BASE_MS), remark, }; if (requestId) log.requestId = requestId; logs.unshift(log); // 新流水置顶(倒序查询天然靠前) return log; } /* ================= 资金工单(t_user_balance_request) ================= */ export interface BalanceRequest { id: number; bizNo: string; type: number; // 100/101/202/203/300 userId: string; institutionId: number; campusId: number; /** 仅 type=300(转结)有值,其余 0 */ toInstitutionId: number; toCampusId: number; money: number; // 分 approved: 0 | 1 | -1 | -2; // 0 待审 / 1 通过 / -1 驳回 / -2 撤销 applicantId: string; approverId: string | null; remark: string; /** 审批意见(驳回必填;端点 15 responseMsg) */ responseMsg: 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; const requests: BalanceRequest[] = []; (function seedRequests() { 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 type = pick(requestTypes); const requestTime = rfc3339(BASE_MS - i * 7200_000); // type=300(转结)有目标校区,其余无 const isTransfer = type === 300; requests.push({ id: ++reqSeq, bizNo: `REQ${(700000 + i).toString()}`, type, userId: u.userId, institutionId: 1, campusId: u.campusId, toInstitutionId: isTransfer ? 1 : 0, toCampusId: isTransfer ? pick(campuses.filter((c) => c.campusId !== u.campusId)).campusId : 0, money: randInt(300, 5000) * 100, approved, applicantId: u.userId, approverId: approved === 0 ? null : ulid(302), remark: '工单', responseMsg: approved === -1 ? '材料不齐,请补充凭证后重新提交' : '', requestTime, responseTime: approved === 0 ? null : rfc3339(Date.parse(requestTime) + 3600_000), }); } 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) => { if (f.type !== undefined && r.type !== f.type) return false; if (f.approved !== undefined && r.approved !== f.approved) return false; if (f.userId && r.userId !== f.userId) return false; if (f.institutionId && f.institutionId !== 1) return false; if (f.campusId && f.campusId !== 0 && r.campusId !== f.campusId) return false; return true; }); } export function findRequest(id: number): BalanceRequest | undefined { return requests.find((r) => r.id === id); } export function createRequest( type: number, userId: string, campusId: number, money: number, remark: string, applicantId: string, toInstitutionId = 0, toCampusId = 0, ): BalanceRequest { const r: BalanceRequest = { id: ++reqSeq, bizNo: `REQ${(700000 + reqSeq).toString()}`, type, userId, institutionId: 1, campusId, toInstitutionId, toCampusId, money, approved: 0, applicantId, approverId: null, remark, responseMsg: '', requestTime: rfc3339(BASE_MS), responseTime: null, }; requests.unshift(r); return r; } /* ================= 活动(t_courses_activity) ================= */ export interface ActivitySign { id: number; activityId: number; userId: string; status: number; // 0 取消 / 1 报名 / 2 参加 / 3 报课 / 4 过期(Go models_activity.go:31 ActivitySignedCancelled=0) recommendUserId: string | null; money: number; // 分 /** 报名业务时间(契约 C1:`createAt`→`createTime`;端点 24/28 signeds 行) */ createTime: string; } export interface Activity { id: number; institutionId: number; campusId: number; type: 2 | 3 | 4; // 2 充值赠送 / 3 代金券 / 4 老带新(Go models_activity.go:16-20 ActivityTypeRecharge/Voucher/Recommend) name: string; status: 0 | 1; // 0 停用 / 1 启用(注意:status=0 仍允许报名,契约 §2.2) numberSign: number; total: number; signed: number; /** 活动金额(分;v1.6 E6:活动级 `money`,充值赠送额度/老带新奖励额/特殊费用额,区别于 signed 行 money) */ money: number; signable: boolean; /** 开始时间(契约 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, createTime: rfc3339(BASE_MS - randInt(1, 30) * 86400_000) }; } const activities: Activity[] = [ { // type=4 老带新(Go ActivityTypeRecommend);status 覆盖 0取消/1报名/2参加/3报课 id: 1, institutionId: 1, campusId: 11, type: 4, name: '老带新推荐(数学)', status: 1, numberSign: 5, total: 100, signed: 4, 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), mkSign(1, ulid(204), 0, ulid(201), 20000), mkSign(1, ulid(203), 2, null, 20000), mkSign(1, ulid(205), 3, null, 20000), ], }, { // type=2 充值赠送(Go ActivityTypeRecharge);status 覆盖 1报名/4过期/0取消 id: 2, institutionId: 1, campusId: 12, type: 2, name: '充值赠送 10%', status: 1, numberSign: 3, total: 200, signed: 2, 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), 4, null, 15000), mkSign(2, ulid(204), 0, null, 8000)], }, { // type=3 代金券(Go ActivityTypeVoucher);活动 status=0 停用(仍允许报名,契约 §2.2) id: 3, institutionId: 1, campusId: 11, type: 3, name: '代金券减免', status: 0, numberSign: 1, 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)], }, { // type=2 充值赠送;暂无报名 id: 4, institutionId: 1, campusId: 13, type: 2, name: '新学期充值有礼', status: 1, numberSign: 0, total: 150, signed: 0, money: 15000, signable: true, startTime: rfc3339(BASE_MS - 5 * 86400_000), endTime: rfc3339(BASE_MS + 35 * 86400_000), signeds: [], }, { // type=4 老带新;2 条有效报名 id: 5, institutionId: 1, campusId: 12, type: 4, name: '老带新推荐(物理)', status: 1, numberSign: 2, 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)], }, ]; export function queryActivities(f: { type?: number; status?: number; keyword?: string; institutionId?: number; campusId?: number }): Activity[] { return activities.filter((a) => { if (f.type !== undefined && a.type !== f.type) return false; if (f.status !== undefined && a.status !== f.status) return false; if (f.institutionId && f.institutionId !== 1) return false; if (f.campusId && f.campusId !== 0 && a.campusId !== f.campusId) return false; if (f.keyword && !a.name.includes(f.keyword)) return false; return true; }); } export function findActivity(id: number): Activity | undefined { return activities.find((a) => a.id === id); } /* ================= 课程 / 排班 / 打卡 ================= */ export interface CourseRecord { id: number; courseId: number; teacherId: string; campusId: number; /** 地址(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 已结束 /** 逐学生(C3:`attendance/payCost`→`type/pay/cost/giveCost`) */ students: { userId: string; type: number; pay: number; cost: number; giveCost: number }[]; } export interface Course { id: number; institutionId: number; name: string; classify: string; status: 0 | 1; teacherIds: string[]; records: CourseRecord[]; } let recordSeq = 6000; 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, address, week, startTime: rfc3339(start), endTime: rfc3339(start + 90 * 60_000), status, students: [ { 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 }, ], }; } const courses: Course[] = [ { id: 1, institutionId: 1, name: '数学 8 班', classify: '数学', status: 1, teacherIds: [ulid(101)], records: [ 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, '城西校区 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, '滨江校区 4 楼 401 教室', '6', -7, 2)], }, ]; export function queryCourses(f: { classify?: string; status?: number; keyword?: string; institutionId?: number; campusId?: number }): Course[] { return courses.filter((c) => { if (f.classify && c.classify !== f.classify) return false; if (f.status !== undefined && c.status !== f.status) return false; if (f.institutionId && f.institutionId !== 1) return false; if (f.keyword && !c.name.includes(f.keyword)) return false; if (f.campusId && f.campusId !== 0 && !c.records.some((r) => r.campusId === f.campusId)) return false; return true; }); } export function findCourse(id: number): Course | undefined { return courses.find((c) => c.id === id); } export function findRecord(courseId: number, recordId: number): CourseRecord | undefined { return findCourse(courseId)?.records.find((r) => r.id === recordId); } /* ================= 分账(Nested Set 树) ================= */ export interface DivideNode { userId: string; realName: string; parentUserId: string | null; /** 层级(根=1;Go MaxDivideDepth=100,业务约束 3 层) */ layer: number; /** 每次分成金额(元;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, layer: 1, quota: 500, cumulativeIncome: 0, children: [ { userId: ulid(101), realName: '张婷', parentUserId: ulid(303), layer: 2, quota: 150, cumulativeIncome: 1200000, children: [ { userId: ulid(201), realName: '王梓涵', parentUserId: ulid(101), layer: 3, quota: 50, cumulativeIncome: 300000, children: [] }, { userId: ulid(202), realName: '陈一诺', parentUserId: ulid(101), layer: 3, quota: 0, cumulativeIncome: 0, children: [] }, // quota=0(新节点默认) ], }, { userId: ulid(102), realName: '李强', parentUserId: ulid(303), layer: 2, quota: 100, cumulativeIncome: 800000, children: [{ userId: ulid(203), realName: '李沐宸', parentUserId: ulid(102), layer: 3, quota: 30, cumulativeIncome: 200000, children: [] }], }, ], }, ]; export function getDivideTree(): DivideNode[] { return divideTree; } export function findDivideNode(userId: string): DivideNode | undefined { let found: DivideNode | undefined; const walk = (nodes: DivideNode[]): void => { for (const n of nodes) { if (n.userId === userId) found = n; walk(n.children); } }; walk(divideTree); return found; } /* ================= 课酬方案(v1.9 两级结构:方案 + 明细) ================= */ /** 课酬明细行(t_course_payment_detail;金额存分,教龄/比例为 number,percentage 为百分比数 0~100)。 */ export interface PayrollDetail { detailId: number; /** 老师(可空;null = 机构级通用档) */ teacherId: string | null; classifyId: 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; /** 计算方式名(明细级 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, 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.details.some((d) => d.classifyId === f.classifyId)) return false; return true; }); } 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; /** 上级类型 id(0 = 顶级) */ parentId: number; } export const expenditureTypes: ExpenditureType[] = [ { 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(id: number): ExpenditureType | undefined { return expenditureTypes.find((t) => t.id === id); } /* ================= 幂等存储(写端点重复提交 → repeated=true) ================= */ const idempotent = new Map>(); export function idempotentKey(method: string, requestId: string): string { return `${method}:${requestId}`; } export function takeIdempotent(key: string): Record | undefined { return idempotent.get(key); } export function setIdempotent(key: string, result: Record): void { idempotent.set(key, result); } /* ================= 金额/字段便捷校验(端点侧参数校验) ================= */ export { fmt, toCents };