data.ts 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663
  1. /**
  2. * data.ts —— mock 数据源(academic-mock-plan §6 数据策略 + §4 行为契约)。
  3. *
  4. * 定位:dev 会话内单例内存数据(刷新即重置,不落 localStorage)。承载:
  5. * - 固定作用域实体(3 校区 / 10 用户,与原型一致);
  6. * - 账户余额(全局 + 校区,分整数);
  7. * - 资金流水(每用户 320 条 → 覆盖分页边界:total>200 / pageSize 上限 200 / 越界页空集);
  8. * - 活动 / 课程排班 / 分账树 / 课酬方案 / 资金工单(工单驱动资金操作);
  9. * - 幂等存储(同 requestId 重复提交 → repeated=true)+ 余额/流水的可变状态(充值/退费/清零/转结改余额并追加流水)。
  10. *
  11. * 契约对齐(academic-frontend-contract-v1.md,冻结):
  12. * - 流水 type 编码 = §10.2 编码表(100 充值 + / 101,201 消费 − / 207 退费 − / 208 清零 − / 106,206 转结 等);
  13. * - 金额恒**分**整数存、对外输出**字符串两位小数**(money.ts);
  14. * - 时间恒 RFC3339 +08:00(time.ts);用户 ID 字符串、业务主键 int64、单号 bizNo 字符串。
  15. *
  16. * 本文件仅在 dev + VITE_API_MOCK 下被加载(http.ts 动态 import 门控),不进 dist。
  17. */
  18. import { fmt, toCents } from '@/api/mock/money';
  19. import { rfc3339 } from '@/api/mock/time';
  20. /* ================= 种子随机(确定性,保证每次会话数据一致) ================= */
  21. function mulberry32(seed: number): () => number {
  22. let a = seed | 0;
  23. return () => {
  24. a = (a + 0x6d2b79f5) | 0;
  25. let t = Math.imul(a ^ (a >>> 15), 1 | a);
  26. t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
  27. return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  28. };
  29. }
  30. const rand = mulberry32(20261109);
  31. const randInt = (min: number, max: number): number => Math.floor(rand() * (max - min + 1)) + min;
  32. const pick = <T>(arr: readonly T[]): T => arr[Math.floor(rand() * arr.length)];
  33. /** 基准时刻:2026-11-09T12:00:00+08:00(数据在其前 60 天分布,确定性) */
  34. const BASE_MS = Date.parse('2026-11-09T12:00:00+08:00');
  35. /* ================= 作用域实体 ================= */
  36. export interface MockCampus {
  37. campusId: number;
  38. institutionId: number;
  39. name: string;
  40. }
  41. export interface MockUser {
  42. /** canonical_user_id(ULID 26 位字符串,契约 §1.5) */
  43. userId: string;
  44. realName: string;
  45. personType: string;
  46. /** 脱敏手机号 */
  47. phone: string;
  48. /** 归属校区(mock 简化:每用户主校区) */
  49. campusId: number;
  50. role: string;
  51. }
  52. export const campuses: MockCampus[] = [
  53. { campusId: 11, institutionId: 1, name: '滨江校区' },
  54. { campusId: 12, institutionId: 1, name: '城西校区' },
  55. { campusId: 13, institutionId: 1, name: '高新校区' },
  56. ];
  57. // 契约 §1.5:canonical_user_id 恒 26 位 ULID(E8:原生成 23~24 位,补到 26)。
  58. const ulid = (n: number): string => `01J2M${String(n).padStart(21, '0')}`;
  59. export const users: MockUser[] = [
  60. { userId: ulid(101), realName: '张婷', personType: 'staff', phone: '135****3322', campusId: 11, role: 'teacher' },
  61. { userId: ulid(102), realName: '李强', personType: 'staff', phone: '135****4477', campusId: 12, role: 'teacher' },
  62. { userId: ulid(201), realName: '王梓涵', personType: 'student', phone: '139****2211', campusId: 11, role: 'student' },
  63. { userId: ulid(202), realName: '陈一诺', personType: 'student', phone: '137****6644', campusId: 11, role: 'student' },
  64. { userId: ulid(203), realName: '李沐宸', personType: 'student', phone: '138****9900', campusId: 12, role: 'student' },
  65. { userId: ulid(204), realName: '赵雨桐', personType: 'student', phone: '136****5511', campusId: 11, role: 'student' },
  66. { userId: ulid(205), realName: '刘子墨', personType: 'student', phone: '133****7788', campusId: 12, role: 'student' },
  67. { userId: ulid(301), realName: '周敏', personType: 'staff', phone: '132****8899', campusId: 11, role: 'academic' },
  68. { userId: ulid(302), realName: '财务管理员', personType: 'staff', phone: '131****6633', campusId: 11, role: 'finance' },
  69. { userId: ulid(303), realName: '超级管理员', personType: 'staff', phone: '130****1122', campusId: 11, role: 'super_admin' },
  70. ];
  71. /** 合法作用域校验(mock 侧 1004 越界判定:campusId 不在集合或 institutionId 不在 {1,0} → 越界)。 */
  72. export function inScope(institutionId: number, campusId: number | undefined | null): boolean {
  73. if (institutionId !== 1 && institutionId !== 0) return false;
  74. if (campusId === undefined || campusId === null || campusId === 0) return true;
  75. return campuses.some((c) => c.campusId === campusId);
  76. }
  77. export function findUser(userId: string): MockUser | undefined {
  78. return users.find((u) => u.userId === userId);
  79. }
  80. export function findCampus(campusId: number): MockCampus | undefined {
  81. return campuses.find((c) => c.campusId === campusId);
  82. }
  83. /* ================= 账户余额(分整数;全局 + 校区) ================= */
  84. interface Account {
  85. totalBalance: number;
  86. campusBalance: number;
  87. }
  88. const accounts = new Map<string, Account>(); // key = `${userId}@${campusId}`
  89. function accountKey(userId: string, campusId: number): string {
  90. return `${userId}@${campusId}`;
  91. }
  92. function ensureAccount(userId: string, campusId: number, total: number, campus: number): Account {
  93. const k = accountKey(userId, campusId);
  94. let a = accounts.get(k);
  95. if (!a) {
  96. a = { totalBalance: total, campusBalance: campus };
  97. accounts.set(k, a);
  98. }
  99. return a;
  100. }
  101. function getUserTotal(userId: string): number {
  102. let sum = 0;
  103. for (const c of campuses) sum += accounts.get(accountKey(userId, c.campusId))?.totalBalance ?? 0;
  104. return sum;
  105. }
  106. /** 初始化账户:学生/老师有正余额(可退/可清零),1 名 0 余额(3102 触发),1 名负余额。 */
  107. (function seedAccounts() {
  108. const students = users.filter((u) => u.personType === 'student');
  109. students.forEach((u, i) => {
  110. // 王梓涵(201) 滨江:正余额;陈一诺(202) 滨江:0 余额(3102);其余正
  111. const campusTotal = i === 1 ? 0 : randInt(800, 4000) * 100; // 800~4000 元
  112. ensureAccount(u.userId, u.campusId, campusTotal, i === 1 ? 0 : Math.floor(campusTotal * 0.6));
  113. // 部分学生在另一校区也有账户
  114. if (i % 2 === 0) {
  115. const other = campuses.find((c) => c.campusId !== u.campusId)!;
  116. ensureAccount(u.userId, other.campusId, Math.floor(campusTotal * 0.4), 0);
  117. }
  118. });
  119. // 李沐宸(203) 额外给一个负余额校区(3120 清零前置:校区余额>全局 或 余额≤0 场景)
  120. ensureAccount(ulid(203), 13, -5000, 30000);
  121. })();
  122. export function getAccount(userId: string, campusId: number): { total: number; campus: number } {
  123. const a = accounts.get(accountKey(userId, campusId));
  124. return { total: a?.totalBalance ?? 0, campus: a?.campusBalance ?? 0 };
  125. }
  126. /** 当前作用域内的全局余额(campusId=0 表示机构级聚合)。 */
  127. export function getGlobalBalance(userId: string, campusId: number): number {
  128. if (campusId && campusId > 0) return getAccount(userId, campusId).total;
  129. return getUserTotal(userId);
  130. }
  131. /* ================= 资金流水(t_user_expense_log 形态) ================= */
  132. export interface ExpenseLog {
  133. id: number;
  134. userId: string;
  135. institutionId: number;
  136. campusId: number;
  137. /** 流水 type(§10.2 编码表) */
  138. type: number;
  139. /** 金额(分,含方向符号:充值/坏账 +,消费/退费/清零 −) */
  140. money: number;
  141. balanceAfter: number;
  142. /** 业务时间(契约 C1:`createAt` 是第三种写法,正确名为 `createTime` ← legacy `create_time`) */
  143. createTime: string;
  144. remark: string;
  145. requestId?: string;
  146. }
  147. const LOG_TYPE_REMARK: Record<number, string> = {
  148. 100: '充值',
  149. 101: '课程消费(校区)',
  150. 201: '课程消费(校区)',
  151. 104: '课程消费(学校)',
  152. 204: '课程消费(学校)',
  153. 207: '余额退费',
  154. 208: '余额清零',
  155. 106: '转结出',
  156. 206: '转结入',
  157. };
  158. const SEED_TYPES = [100, 101, 101, 201, 104, 207, 208] as const;
  159. const PER_USER_LOGS = 320; // > 200,覆盖分页边界
  160. let logSeq = 90000;
  161. const logs: ExpenseLog[] = [];
  162. (function seedLogs() {
  163. for (const u of users) {
  164. if (u.personType !== 'student') continue; // 仅学生造大量流水
  165. let running = getAccount(u.userId, u.campusId).total;
  166. for (let i = 0; i < PER_USER_LOGS; i++) {
  167. const type = pick(SEED_TYPES);
  168. // 金额 20~3000 元;充值/转结入 为正,其余为负
  169. const yuan = randInt(20, 3000);
  170. // 仅 100(充值)为正;其余消费/退费/清零为负(106/206 转结不入 SEED_TYPES,故不判)
  171. const positive = type === 100;
  172. const money = positive ? yuan * 100 : -yuan * 100;
  173. running = Math.max(running + money, -200000);
  174. const createTime = rfc3339(BASE_MS - i * 3600_000 * randInt(1, 30));
  175. logs.push({
  176. id: ++logSeq,
  177. userId: u.userId,
  178. institutionId: 1,
  179. campusId: u.campusId,
  180. type,
  181. money,
  182. balanceAfter: running,
  183. createTime,
  184. remark: LOG_TYPE_REMARK[type] ?? '流水',
  185. });
  186. }
  187. }
  188. })();
  189. /** 按筛选条件取**完整**流水集(倒序;分页由 envelope.page 负责)。 */
  190. export interface LogFilter {
  191. userId?: string;
  192. type?: number;
  193. from?: string;
  194. to?: string;
  195. institutionId?: number;
  196. campusId?: number;
  197. }
  198. export function queryLogs(f: LogFilter): ExpenseLog[] {
  199. const from = f.from ? Date.parse(`${f.from}T00:00:00+08:00`) : null;
  200. const to = f.to ? Date.parse(`${f.to}T23:59:59.999+08:00`) : null;
  201. return logs
  202. .filter((l) => {
  203. if (f.userId && l.userId !== f.userId) return false;
  204. if (f.type !== undefined && l.type !== f.type) return false;
  205. if (f.institutionId && f.institutionId !== 1) return false;
  206. if (f.campusId && f.campusId !== 0 && l.campusId !== f.campusId) return false;
  207. const t = Date.parse(l.createTime);
  208. if (from !== null && t < from) return false;
  209. if (to !== null && t > to) return false;
  210. return true;
  211. })
  212. .sort((a, b) => (a.createTime < b.createTime ? 1 : a.createTime > b.createTime ? -1 : b.id - a.id));
  213. }
  214. /** 追加一条流水(写端点共用;返回新 logId)。 */
  215. export function appendLog(userId: string, campusId: number, type: number, money: number, remark: string, requestId?: string): ExpenseLog {
  216. const a = ensureAccount(userId, campusId, 0, 0);
  217. a.campusBalance += money;
  218. a.totalBalance += money;
  219. const log: ExpenseLog = {
  220. id: ++logSeq,
  221. userId,
  222. institutionId: 1,
  223. campusId,
  224. type,
  225. money,
  226. balanceAfter: a.totalBalance,
  227. createTime: rfc3339(BASE_MS),
  228. remark,
  229. };
  230. if (requestId) log.requestId = requestId;
  231. logs.unshift(log); // 新流水置顶(倒序查询天然靠前)
  232. return log;
  233. }
  234. /* ================= 资金工单(t_user_balance_request) ================= */
  235. export interface BalanceRequest {
  236. id: number;
  237. bizNo: string;
  238. type: number; // 100/101/202/203/300
  239. userId: string;
  240. institutionId: number;
  241. campusId: number;
  242. /** 仅 type=300(转结)有值,其余 0 */
  243. toInstitutionId: number;
  244. toCampusId: number;
  245. money: number; // 分
  246. approved: 0 | 1 | -1 | -2; // 0 待审 / 1 通过 / -1 驳回 / -2 撤销
  247. applicantId: string;
  248. approverId: string | null;
  249. remark: string;
  250. /** 审批意见(驳回必填;端点 15 responseMsg) */
  251. responseMsg: string;
  252. /** 申请时间(业务时间 ← request_time;v1.6 B2:`createdAt` 是迁移时生成的新列,不下发) */
  253. requestTime: string;
  254. /** 审批时间(业务时间 ← response_time;未审批为 null) */
  255. responseTime: string | null;
  256. }
  257. const requestTypes = [100, 101, 202, 203, 300] as const;
  258. let reqSeq = 5000;
  259. const requests: BalanceRequest[] = [];
  260. (function seedRequests() {
  261. for (let i = 0; i < 60; i++) {
  262. const u = pick(users.filter((x) => x.personType === 'student'));
  263. const approved = pick([0, 0, 1, -1, -2] as const);
  264. const type = pick(requestTypes);
  265. const requestTime = rfc3339(BASE_MS - i * 7200_000);
  266. // type=300(转结)有目标校区,其余无
  267. const isTransfer = type === 300;
  268. requests.push({
  269. id: ++reqSeq,
  270. bizNo: `REQ${(700000 + i).toString()}`,
  271. type,
  272. userId: u.userId,
  273. institutionId: 1,
  274. campusId: u.campusId,
  275. toInstitutionId: isTransfer ? 1 : 0,
  276. toCampusId: isTransfer ? pick(campuses.filter((c) => c.campusId !== u.campusId)).campusId : 0,
  277. money: randInt(300, 5000) * 100,
  278. approved,
  279. applicantId: u.userId,
  280. approverId: approved === 0 ? null : ulid(302),
  281. remark: '工单',
  282. responseMsg: approved === -1 ? '材料不齐,请补充凭证后重新提交' : '',
  283. requestTime,
  284. responseTime: approved === 0 ? null : rfc3339(Date.parse(requestTime) + 3600_000),
  285. });
  286. }
  287. requests.sort((a, b) => (a.requestTime < b.requestTime ? 1 : -1));
  288. })();
  289. export function queryRequests(f: { type?: number; approved?: number; userId?: string; institutionId?: number; campusId?: number }): BalanceRequest[] {
  290. return requests.filter((r) => {
  291. if (f.type !== undefined && r.type !== f.type) return false;
  292. if (f.approved !== undefined && r.approved !== f.approved) return false;
  293. if (f.userId && r.userId !== f.userId) return false;
  294. if (f.institutionId && f.institutionId !== 1) return false;
  295. if (f.campusId && f.campusId !== 0 && r.campusId !== f.campusId) return false;
  296. return true;
  297. });
  298. }
  299. export function findRequest(id: number): BalanceRequest | undefined {
  300. return requests.find((r) => r.id === id);
  301. }
  302. export function createRequest(
  303. type: number,
  304. userId: string,
  305. campusId: number,
  306. money: number,
  307. remark: string,
  308. applicantId: string,
  309. toInstitutionId = 0,
  310. toCampusId = 0,
  311. ): BalanceRequest {
  312. const r: BalanceRequest = {
  313. id: ++reqSeq,
  314. bizNo: `REQ${(700000 + reqSeq).toString()}`,
  315. type,
  316. userId,
  317. institutionId: 1,
  318. campusId,
  319. toInstitutionId,
  320. toCampusId,
  321. money,
  322. approved: 0,
  323. applicantId,
  324. approverId: null,
  325. remark,
  326. responseMsg: '',
  327. requestTime: rfc3339(BASE_MS),
  328. responseTime: null,
  329. };
  330. requests.unshift(r);
  331. return r;
  332. }
  333. /* ================= 活动(t_courses_activity) ================= */
  334. export interface ActivitySign {
  335. id: number;
  336. activityId: number;
  337. userId: string;
  338. status: number; // 0 取消 / 1 报名 / 2 参加 / 3 报课 / 4 过期(Go models_activity.go:31 ActivitySignedCancelled=0)
  339. recommendUserId: string | null;
  340. money: number; // 分
  341. /** 报名业务时间(契约 C1:`createAt`→`createTime`;端点 24/28 signeds 行) */
  342. createTime: string;
  343. }
  344. export interface Activity {
  345. id: number;
  346. institutionId: number;
  347. campusId: number;
  348. type: 2 | 3 | 4; // 2 充值赠送 / 3 代金券 / 4 老带新(Go models_activity.go:16-20 ActivityTypeRecharge/Voucher/Recommend)
  349. name: string;
  350. status: 0 | 1; // 0 停用 / 1 启用(注意:status=0 仍允许报名,契约 §2.2)
  351. numberSign: number;
  352. total: number;
  353. signed: number;
  354. /** 活动金额(分;v1.6 E6:活动级 `money`,充值赠送额度/老带新奖励额/特殊费用额,区别于 signed 行 money) */
  355. money: number;
  356. signable: boolean;
  357. /** 开始时间(契约 C2:源列 start_time,活动与排班统一 startTime,撤回 startAt 方案) */
  358. startTime: string;
  359. /** 结束时间(契约 C2:源列 end_time,统一 endTime) */
  360. endTime: string;
  361. signeds: ActivitySign[];
  362. }
  363. let signSeq = 3000;
  364. function mkSign(activityId: number, userId: string, status: number, recommendUserId: string | null, money: number): ActivitySign {
  365. return { id: ++signSeq, activityId, userId, status, recommendUserId, money, createTime: rfc3339(BASE_MS - randInt(1, 30) * 86400_000) };
  366. }
  367. const activities: Activity[] = [
  368. {
  369. // type=4 老带新(Go ActivityTypeRecommend);status 覆盖 0取消/1报名/2参加/3报课
  370. id: 1, institutionId: 1, campusId: 11, type: 4, name: '老带新推荐(数学)', status: 1, numberSign: 5,
  371. total: 100, signed: 4, money: 20000, signable: true, startTime: rfc3339(BASE_MS - 40 * 86400_000), endTime: rfc3339(BASE_MS + 20 * 86400_000),
  372. signeds: [
  373. mkSign(1, ulid(201), 1, null, 20000),
  374. mkSign(1, ulid(202), 1, ulid(201), 20000),
  375. mkSign(1, ulid(204), 0, ulid(201), 20000),
  376. mkSign(1, ulid(203), 2, null, 20000),
  377. mkSign(1, ulid(205), 3, null, 20000),
  378. ],
  379. },
  380. {
  381. // type=2 充值赠送(Go ActivityTypeRecharge);status 覆盖 1报名/4过期/0取消
  382. id: 2, institutionId: 1, campusId: 12, type: 2, name: '充值赠送 10%', status: 1, numberSign: 3,
  383. total: 200, signed: 2, money: 10000, signable: true, startTime: rfc3339(BASE_MS - 20 * 86400_000), endTime: rfc3339(BASE_MS + 40 * 86400_000),
  384. signeds: [mkSign(2, ulid(203), 1, null, 10000), mkSign(2, ulid(205), 4, null, 15000), mkSign(2, ulid(204), 0, null, 8000)],
  385. },
  386. {
  387. // type=3 代金券(Go ActivityTypeVoucher);活动 status=0 停用(仍允许报名,契约 §2.2)
  388. id: 3, institutionId: 1, campusId: 11, type: 3, name: '代金券减免', status: 0, numberSign: 1,
  389. total: 50, signed: 1, money: 5000, signable: false, startTime: rfc3339(BASE_MS - 60 * 86400_000), endTime: rfc3339(BASE_MS - 5 * 86400_000),
  390. signeds: [mkSign(3, ulid(202), 1, null, 5000)],
  391. },
  392. {
  393. // type=2 充值赠送;暂无报名
  394. id: 4, institutionId: 1, campusId: 13, type: 2, name: '新学期充值有礼', status: 1, numberSign: 0,
  395. total: 150, signed: 0, money: 15000, signable: true, startTime: rfc3339(BASE_MS - 5 * 86400_000), endTime: rfc3339(BASE_MS + 35 * 86400_000),
  396. signeds: [],
  397. },
  398. {
  399. // type=4 老带新;2 条有效报名
  400. id: 5, institutionId: 1, campusId: 12, type: 4, name: '老带新推荐(物理)', status: 1, numberSign: 2,
  401. total: 80, signed: 2, money: 30000, signable: true, startTime: rfc3339(BASE_MS - 15 * 86400_000), endTime: rfc3339(BASE_MS + 25 * 86400_000),
  402. signeds: [mkSign(5, ulid(203), 1, null, 30000), mkSign(5, ulid(205), 1, ulid(203), 30000)],
  403. },
  404. ];
  405. export function queryActivities(f: { type?: number; status?: number; keyword?: string; institutionId?: number; campusId?: number }): Activity[] {
  406. return activities.filter((a) => {
  407. if (f.type !== undefined && a.type !== f.type) return false;
  408. if (f.status !== undefined && a.status !== f.status) return false;
  409. if (f.institutionId && f.institutionId !== 1) return false;
  410. if (f.campusId && f.campusId !== 0 && a.campusId !== f.campusId) return false;
  411. if (f.keyword && !a.name.includes(f.keyword)) return false;
  412. return true;
  413. });
  414. }
  415. export function findActivity(id: number): Activity | undefined {
  416. return activities.find((a) => a.id === id);
  417. }
  418. /* ================= 课程 / 排班 / 打卡 ================= */
  419. export interface CourseRecord {
  420. id: number;
  421. courseId: number;
  422. teacherId: string;
  423. campusId: number;
  424. /** 地址(v1.8:源列 `address`;**无 room 列**——地址≠教室号,不得顶替) */
  425. address: string;
  426. /** 周(排班规则源列 week VARCHAR(50) DEFAULT '-1';"-1"=全部周 / "0".."6" / "1,3,5" 多值) */
  427. week: string;
  428. startTime: string; // RFC3339
  429. endTime: string;
  430. status: 0 | 1 | 2; // 0 未开始 / 1 进行中 / 2 已结束
  431. /** 逐学生(C3:`attendance/payCost`→`type/pay/cost/giveCost`) */
  432. students: { userId: string; type: number; pay: number; cost: number; giveCost: number }[];
  433. }
  434. export interface Course {
  435. id: number;
  436. institutionId: number;
  437. name: string;
  438. classify: string;
  439. status: 0 | 1;
  440. teacherIds: string[];
  441. records: CourseRecord[];
  442. }
  443. let recordSeq = 6000;
  444. function mkRecord(courseId: number, teacherId: string, campusId: number, address: string, week: string, offsetDays: number, status: 0 | 1 | 2): CourseRecord {
  445. const start = BASE_MS + offsetDays * 86400_000;
  446. const present = (on: boolean): number => (status === 2 && on ? 1 : 0); // 已结束课才有出勤 type=1(正常)
  447. return {
  448. id: ++recordSeq,
  449. courseId,
  450. teacherId,
  451. campusId,
  452. address,
  453. week,
  454. startTime: rfc3339(start),
  455. endTime: rfc3339(start + 90 * 60_000),
  456. status,
  457. students: [
  458. { userId: ulid(201), type: present(true), pay: 10000, cost: 10000, giveCost: 0 },
  459. { userId: ulid(202), type: present(true), pay: 10000, cost: 10000, giveCost: 0 },
  460. { userId: ulid(204), type: present(false), pay: 10000, cost: 10000, giveCost: 0 },
  461. ],
  462. };
  463. }
  464. const courses: Course[] = [
  465. {
  466. id: 1, institutionId: 1, name: '数学 8 班', classify: '数学', status: 1, teacherIds: [ulid(101)],
  467. records: [
  468. mkRecord(1, ulid(101), 11, '滨江校区 3 楼 305 教室', '1,3,5', -3, 2),
  469. mkRecord(1, ulid(101), 11, '滨江校区 3 楼 305 教室', '1,3,5', 0, 0),
  470. mkRecord(1, ulid(101), 11, '滨江校区 5 楼 502 教室', '2,4', 2, 0),
  471. mkRecord(1, ulid(101), 11, '滨江校区 3 楼 305 教室', '-1', 4, 0),
  472. ],
  473. },
  474. {
  475. id: 2, institutionId: 1, name: '物理 3 班', classify: '物理', status: 1, teacherIds: [ulid(102)],
  476. records: [mkRecord(2, ulid(102), 12, '城西校区 1 楼 102 教室', '3', -1, 1), mkRecord(2, ulid(102), 12, '城西校区 1 楼 102 教室', '3', 1, 0)],
  477. },
  478. {
  479. id: 3, institutionId: 1, name: '英语 1 班', classify: '英语', status: 0, teacherIds: [ulid(101)],
  480. records: [mkRecord(3, ulid(101), 11, '滨江校区 4 楼 401 教室', '6', -7, 2)],
  481. },
  482. ];
  483. export function queryCourses(f: { classify?: string; status?: number; keyword?: string; institutionId?: number; campusId?: number }): Course[] {
  484. return courses.filter((c) => {
  485. if (f.classify && c.classify !== f.classify) return false;
  486. if (f.status !== undefined && c.status !== f.status) return false;
  487. if (f.institutionId && f.institutionId !== 1) return false;
  488. if (f.keyword && !c.name.includes(f.keyword)) return false;
  489. if (f.campusId && f.campusId !== 0 && !c.records.some((r) => r.campusId === f.campusId)) return false;
  490. return true;
  491. });
  492. }
  493. export function findCourse(id: number): Course | undefined {
  494. return courses.find((c) => c.id === id);
  495. }
  496. export function findRecord(courseId: number, recordId: number): CourseRecord | undefined {
  497. return findCourse(courseId)?.records.find((r) => r.id === recordId);
  498. }
  499. /* ================= 分账(Nested Set 树) ================= */
  500. export interface DivideNode {
  501. userId: string;
  502. realName: string;
  503. parentUserId: string | null;
  504. /** 层级(根=1;Go MaxDivideDepth=100,业务约束 3 层) */
  505. layer: number;
  506. /** 每次分成金额(元;A2:`quota` 是金额非 0~1 比率,源列 DECIMAL(12,2),与 total_income 同族;0.00 = 该链不分成) */
  507. quota: number;
  508. cumulativeIncome: number; // 分
  509. children: DivideNode[];
  510. }
  511. const divideTree: DivideNode[] = [
  512. {
  513. userId: ulid(303), realName: '超级管理员', parentUserId: null, layer: 1, quota: 500, cumulativeIncome: 0,
  514. children: [
  515. {
  516. userId: ulid(101), realName: '张婷', parentUserId: ulid(303), layer: 2, quota: 150, cumulativeIncome: 1200000,
  517. children: [
  518. { userId: ulid(201), realName: '王梓涵', parentUserId: ulid(101), layer: 3, quota: 50, cumulativeIncome: 300000, children: [] },
  519. { userId: ulid(202), realName: '陈一诺', parentUserId: ulid(101), layer: 3, quota: 0, cumulativeIncome: 0, children: [] }, // quota=0(新节点默认)
  520. ],
  521. },
  522. {
  523. userId: ulid(102), realName: '李强', parentUserId: ulid(303), layer: 2, quota: 100, cumulativeIncome: 800000,
  524. children: [{ userId: ulid(203), realName: '李沐宸', parentUserId: ulid(102), layer: 3, quota: 30, cumulativeIncome: 200000, children: [] }],
  525. },
  526. ],
  527. },
  528. ];
  529. export function getDivideTree(): DivideNode[] {
  530. return divideTree;
  531. }
  532. export function findDivideNode(userId: string): DivideNode | undefined {
  533. let found: DivideNode | undefined;
  534. const walk = (nodes: DivideNode[]): void => {
  535. for (const n of nodes) {
  536. if (n.userId === userId) found = n;
  537. walk(n.children);
  538. }
  539. };
  540. walk(divideTree);
  541. return found;
  542. }
  543. /* ================= 课酬方案(v1.9 两级结构:方案 + 明细) ================= */
  544. /** 课酬明细行(t_course_payment_detail;金额存分,教龄/比例为 number,percentage 为百分比数 0~100)。 */
  545. export interface PayrollDetail {
  546. detailId: number;
  547. /** 老师(可空;null = 机构级通用档) */
  548. teacherId: string | null;
  549. classifyId: number;
  550. classType: number;
  551. gradeId: number;
  552. subjectId: number;
  553. teacherCert: number;
  554. /** 教龄区间(年,3 位小数;源列两列,不得合并为单值) */
  555. teacherExpMin: number;
  556. teacherExpMax: number;
  557. /** 课酬比例(百分比数 0~100,1 位小数;A3:非 0~1 小数) */
  558. percentage: number;
  559. /** 金额上下限 / 奖励(分) */
  560. min: number;
  561. max: number;
  562. reward: number;
  563. /** 计算方式名(明细级 name;方案级为 schemeName) */
  564. displayName: string | null;
  565. /** 明细级启停(方案级为 actived) */
  566. detailActived: 0 | 1;
  567. }
  568. /** 课酬方案(t_course_payment;两级结构顶层)。 */
  569. export interface PayrollPlan {
  570. id: number;
  571. institutionId: number;
  572. campusId: number; // 0 = 机构级方案
  573. schemeName: string;
  574. effectDate: string;
  575. /** 方案级启停(整案停用 = 不参与匹配) */
  576. actived: 0 | 1;
  577. details: PayrollDetail[];
  578. }
  579. let detailSeq = 90000;
  580. const payrollPlans: PayrollPlan[] = [
  581. {
  582. id: 1, institutionId: 1, campusId: 11, schemeName: '滨江校区 2026 秋季课酬方案', effectDate: '2026-09-01', actived: 1,
  583. details: [
  584. { 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 },
  585. { 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 },
  586. ],
  587. },
  588. {
  589. id: 2, institutionId: 1, campusId: 12, schemeName: '城西校区 2026 秋季课酬方案', effectDate: '2026-09-01', actived: 1,
  590. details: [
  591. { 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 },
  592. { 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 },
  593. ],
  594. },
  595. ];
  596. export function queryPayrollPlans(f: { actived?: number; classifyId?: number }): PayrollPlan[] {
  597. return payrollPlans.filter((p) => {
  598. if (f.actived !== undefined && p.actived !== f.actived) return false;
  599. if (f.classifyId !== undefined && !p.details.some((d) => d.classifyId === f.classifyId)) return false;
  600. return true;
  601. });
  602. }
  603. export function findPayrollPlan(id: number): PayrollPlan | undefined {
  604. return payrollPlans.find((p) => p.id === id);
  605. }
  606. /* ================= 收支类型字典(v1.9:type 1=支出/2=收入,删 direction;补 id/parentId) ================= */
  607. export interface ExpenditureType {
  608. id: number;
  609. /** 收支方向编码:1=支出 / 2=收入(与流水 type 同事实,两表均无 direction 列) */
  610. type: number;
  611. name: string;
  612. /** 上级类型 id(0 = 顶级) */
  613. parentId: number;
  614. }
  615. export const expenditureTypes: ExpenditureType[] = [
  616. { id: 1, type: 1, name: '课程消费', parentId: 0 },
  617. { id: 2, type: 1, name: '课程消费(校区)', parentId: 1 },
  618. { id: 3, type: 1, name: '课程消费(学校)', parentId: 1 },
  619. { id: 4, type: 2, name: '线下收入', parentId: 0 },
  620. { id: 5, type: 2, name: '线上收入', parentId: 0 },
  621. ];
  622. export function expenditureTypeOf(id: number): ExpenditureType | undefined {
  623. return expenditureTypes.find((t) => t.id === id);
  624. }
  625. /* ================= 幂等存储(写端点重复提交 → repeated=true) ================= */
  626. const idempotent = new Map<string, Record<string, unknown>>();
  627. export function idempotentKey(method: string, requestId: string): string {
  628. return `${method}:${requestId}`;
  629. }
  630. export function takeIdempotent(key: string): Record<string, unknown> | undefined {
  631. return idempotent.get(key);
  632. }
  633. export function setIdempotent(key: string, result: Record<string, unknown>): void {
  634. idempotent.set(key, result);
  635. }
  636. /* ================= 金额/字段便捷校验(端点侧参数校验) ================= */
  637. export { fmt, toCents };