|
@@ -0,0 +1,580 @@
|
|
|
|
|
+/**
|
|
|
|
|
+ * 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 = <T>(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: '高新校区' },
|
|
|
|
|
+];
|
|
|
|
|
+
|
|
|
|
|
+const ulid = (n: number): string => `01J2M${String(n).padStart(19, '0')}`.slice(0, 26);
|
|
|
|
|
+
|
|
|
|
|
+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<string, Account>(); // 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;
|
|
|
|
|
+ createAt: string;
|
|
|
|
|
+ remark: string;
|
|
|
|
|
+ requestId?: string;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const LOG_TYPE_REMARK: Record<number, string> = {
|
|
|
|
|
+ 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 createAt = 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,
|
|
|
|
|
+ createAt,
|
|
|
|
|
+ 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.createAt);
|
|
|
|
|
+ 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));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 追加一条流水(写端点共用;返回新 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,
|
|
|
|
|
+ createAt: 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;
|
|
|
|
|
+ money: number; // 分
|
|
|
|
|
+ approved: 0 | 1 | -1 | -2; // 0 待审 / 1 通过 / -1 驳回 / -2 撤销
|
|
|
|
|
+ applicantId: string;
|
|
|
|
|
+ approverId: string | null;
|
|
|
|
|
+ remark: string;
|
|
|
|
|
+ createAt: string;
|
|
|
|
|
+}
|
|
|
|
|
+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);
|
|
|
|
|
+ requests.push({
|
|
|
|
|
+ id: ++reqSeq,
|
|
|
|
|
+ bizNo: `REQ${(700000 + i).toString()}`,
|
|
|
|
|
+ type: pick(requestTypes),
|
|
|
|
|
+ userId: u.userId,
|
|
|
|
|
+ institutionId: 1,
|
|
|
|
|
+ campusId: u.campusId,
|
|
|
|
|
+ money: randInt(300, 5000) * 100,
|
|
|
|
|
+ approved,
|
|
|
|
|
+ applicantId: u.userId,
|
|
|
|
|
+ approverId: approved === 0 ? null : ulid(302),
|
|
|
|
|
+ remark: '工单',
|
|
|
|
|
+ createAt: rfc3339(BASE_MS - i * 7200_000),
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ requests.sort((a, b) => (a.createAt < b.createAt ? 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): BalanceRequest {
|
|
|
|
|
+ const r: BalanceRequest = {
|
|
|
|
|
+ id: ++reqSeq,
|
|
|
|
|
+ bizNo: `REQ${(700000 + reqSeq).toString()}`,
|
|
|
|
|
+ type,
|
|
|
|
|
+ userId,
|
|
|
|
|
+ institutionId: 1,
|
|
|
|
|
+ campusId,
|
|
|
|
|
+ money,
|
|
|
|
|
+ approved: 0,
|
|
|
|
|
+ applicantId,
|
|
|
|
|
+ approverId: null,
|
|
|
|
|
+ remark,
|
|
|
|
|
+ createAt: rfc3339(BASE_MS),
|
|
|
|
|
+ };
|
|
|
|
|
+ requests.unshift(r);
|
|
|
|
|
+ return r;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ================= 活动(t_courses_activity) ================= */
|
|
|
|
|
+export interface ActivitySign {
|
|
|
|
|
+ id: number;
|
|
|
|
|
+ activityId: number;
|
|
|
|
|
+ userId: string;
|
|
|
|
|
+ status: number; // 0 未审核 / 1 通过 / -1 取消
|
|
|
|
|
+ recommendUserId: string | null;
|
|
|
|
|
+ money: number; // 分
|
|
|
|
|
+ createAt: string;
|
|
|
|
|
+}
|
|
|
|
|
+export interface Activity {
|
|
|
|
|
+ id: number;
|
|
|
|
|
+ institutionId: number;
|
|
|
|
|
+ campusId: number;
|
|
|
|
|
+ type: 2 | 3 | 4; // 2 老带新 / 3 充值赠送 / 4 特殊费用
|
|
|
|
|
+ name: string;
|
|
|
|
|
+ status: 0 | 1; // 0 停用 / 1 启用(注意:status=0 仍允许报名,契约 §2.2)
|
|
|
|
|
+ numberSign: number;
|
|
|
|
|
+ total: number;
|
|
|
|
|
+ signed: number;
|
|
|
|
|
+ signable: boolean;
|
|
|
|
|
+ startAt: string;
|
|
|
|
|
+ endAt: 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) };
|
|
|
|
|
+}
|
|
|
|
|
+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),
|
|
|
|
|
+ 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), -1, null, 20000),
|
|
|
|
|
+ mkSign(1, ulid(205), 1, null, 20000),
|
|
|
|
|
+ mkSign(1, ulid(201), 1, ulid(204), 20000),
|
|
|
|
|
+ ],
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ 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),
|
|
|
|
|
+ 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),
|
|
|
|
|
+ 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),
|
|
|
|
|
+ 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),
|
|
|
|
|
+ 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;
|
|
|
|
|
+ room: string;
|
|
|
|
|
+ startTime: string; // RFC3339
|
|
|
|
|
+ endTime: string;
|
|
|
|
|
+ status: 0 | 1 | 2; // 0 未开始 / 1 进行中 / 2 已结束
|
|
|
|
|
+ students: { userId: string; attendance: 0 | 1; payCost: 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, room: string, offsetDays: number, status: 0 | 1 | 2): CourseRecord {
|
|
|
|
|
+ const start = BASE_MS + offsetDays * 86400_000;
|
|
|
|
|
+ return {
|
|
|
|
|
+ id: ++recordSeq,
|
|
|
|
|
+ courseId,
|
|
|
|
|
+ teacherId,
|
|
|
|
|
+ campusId,
|
|
|
|
|
+ room,
|
|
|
|
|
+ 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 },
|
|
|
|
|
+ ],
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+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),
|
|
|
|
|
+ ],
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ 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)],
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ id: 3, institutionId: 1, name: '英语 1 班', classify: '英语', status: 0, teacherIds: [ulid(101)],
|
|
|
|
|
+ records: [mkRecord(3, ulid(101), 11, 'C101', -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;
|
|
|
|
|
+ quota: number; // 0~1 小数
|
|
|
|
|
+ cumulativeIncome: number; // 分
|
|
|
|
|
+ children: DivideNode[];
|
|
|
|
|
+}
|
|
|
|
|
+const divideTree: DivideNode[] = [
|
|
|
|
|
+ {
|
|
|
|
|
+ userId: ulid(303), realName: '超级管理员', parentUserId: null, quota: 1, cumulativeIncome: 0,
|
|
|
|
|
+ children: [
|
|
|
|
|
+ {
|
|
|
|
|
+ userId: ulid(101), realName: '张婷', parentUserId: ulid(303), quota: 0.3, cumulativeIncome: 1200000,
|
|
|
|
|
+ children: [
|
|
|
|
|
+ { userId: ulid(201), realName: '王梓涵', parentUserId: ulid(101), quota: 0.1, 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: [] }],
|
|
|
|
|
+ },
|
|
|
|
|
+ ],
|
|
|
|
|
+ },
|
|
|
|
|
+];
|
|
|
|
|
+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;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ================= 课酬方案 ================= */
|
|
|
|
|
+export interface PayrollPlan {
|
|
|
|
|
+ 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;
|
|
|
|
|
+}
|
|
|
|
|
+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' },
|
|
|
|
|
+];
|
|
|
|
|
+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;
|
|
|
|
|
+ return true;
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+export function findPayrollPlan(id: number): PayrollPlan | undefined {
|
|
|
|
|
+ return payrollPlans.find((p) => p.id === id);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ================= 收支类型字典 + 收支记录 ================= */
|
|
|
|
|
+export interface ExpenditureType {
|
|
|
|
|
+ type: number;
|
|
|
|
|
+ name: string;
|
|
|
|
|
+ direction: 1 | -1; // 1 收 / -1 支
|
|
|
|
|
+}
|
|
|
|
|
+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 },
|
|
|
|
|
+];
|
|
|
|
|
+export function expenditureTypeOf(type: number): ExpenditureType | undefined {
|
|
|
|
|
+ return expenditureTypes.find((t) => t.type === type);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ================= 幂等存储(写端点重复提交 → repeated=true) ================= */
|
|
|
|
|
+const idempotent = new Map<string, Record<string, unknown>>();
|
|
|
|
|
+export function idempotentKey(method: string, requestId: string): string {
|
|
|
|
|
+ return `${method}:${requestId}`;
|
|
|
|
|
+}
|
|
|
|
|
+export function takeIdempotent(key: string): Record<string, unknown> | undefined {
|
|
|
|
|
+ return idempotent.get(key);
|
|
|
|
|
+}
|
|
|
|
|
+export function setIdempotent(key: string, result: Record<string, unknown>): void {
|
|
|
|
|
+ idempotent.set(key, result);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ================= 金额/字段便捷校验(端点侧参数校验) ================= */
|
|
|
|
|
+export { fmt, toCents };
|