/** * routes/courses.ts —— 课程/排班/打卡端点 mock(academic 契约 §2.4 端点 44~56,PC 侧 13 个;端点 57 属移动端不做)。 * * 契约对齐(academic-frontend-contract-v1.md v1.8/v1.9): * - 端点 49 生成课表:同 (courses + start_time) 幂等(已生成不重复); * - 端点 51 finish 重操作:一次调用产生 扣课时 + 余额变动 + 分账 + 课酬(mock 返回聚合结果 + changed 标记, * 前端须二次确认 + loading 锁 + 失败刷新整页——交互在页面层,mock 只保证契约返回); * - 端点 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。 */ import { fail, ok, page } from '@/api/mock/envelope'; import { fmt, toCents } from '@/api/mock/money'; import { inScope, findUser, findCourse, queryCourses, appendLog } from '@/api/mock/data'; import type { Course, CourseRecord } from '@/api/mock/data'; import type { MockCtx, MockRoute } from '@/api/mock/adapter'; import type { R } from '@/types/api'; function checkScope(ctx: MockCtx): R | undefined { const institutionId = ctx.query.institutionId !== undefined ? Number(ctx.query.institutionId) : 1; const campusId = ctx.query.campusId !== undefined ? Number(ctx.query.campusId) : 0; return inScope(institutionId, campusId) ? undefined : fail(1004, '作用域越界:当前账号无权访问该机构/校区'); } function courseRow(c: Course): Record { return { id: c.id, institutionId: c.institutionId, name: c.name, classify: c.classify, status: c.status, teacherIds: c.teacherIds, recordCount: c.records.length, }; } export const courseRoutes: MockRoute[] = [ /* 44. GET /api/admin/courses —— 课程列表 */ { method: 'GET', path: '/api/admin/courses', handler: (ctx) => { const denied = checkScope(ctx); if (denied) return denied; const rows = queryCourses({ classify: ctx.query.classify, status: ctx.query.status !== undefined ? Number(ctx.query.status) : undefined, keyword: ctx.query.keyword, institutionId: ctx.query.institutionId ? Number(ctx.query.institutionId) : 1, campusId: ctx.query.campusId ? Number(ctx.query.campusId) : 0, }); return page(rows.map(courseRow), Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10)); }, }, /* 45a. POST /api/admin/courses —— 新建课程 */ { method: 'POST', path: '/api/admin/courses', handler: (ctx) => { const denied = checkScope(ctx); if (denied) return denied; const name = ctx.body?.name as string | undefined; if (!name) return fail(2001, 'name 缺失'); return ok({ id: 100 + Math.floor(Math.random() * 900) }); }, }, /* 45b. PUT /api/admin/courses/:id —— 更新课程 */ { method: 'PUT', path: '/api/admin/courses/:id', handler: (ctx) => { const denied = checkScope(ctx); if (denied) return denied; const c = findCourse(Number(ctx.params.id)); if (!c) return fail(3001, '课程不存在'); return ok({ id: c.id }); }, }, /* 46. GET /api/admin/courses/:id/classes —— 排班规则列表 */ { method: 'GET', path: '/api/admin/courses/:id/classes', handler: (ctx) => { const denied = checkScope(ctx); if (denied) return denied; const c = findCourse(Number(ctx.params.id)); if (!c) return fail(3001, '课程不存在'); // 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: r.week, start: r.startTime, end: r.endTime, campusId: r.campusId, })); return page(rules, Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10)); }, }, /* 47. POST /api/admin/courses/:id/classes —— 新建排班规则(3122 冲突) */ { method: 'POST', path: '/api/admin/courses/:id/classes', handler: (ctx) => { const denied = checkScope(ctx); if (denied) return denied; const c = findCourse(Number(ctx.params.id)); if (!c) return fail(3001, '课程不存在'); if (ctx.body?.teacherId === undefined || ctx.body?.week === undefined) return fail(2001, 'teacherId/week 缺失'); // 3122:同老师同周同校区已有规则 → 排班冲突 const exists = c.records.some((r) => r.teacherId === (ctx.body?.teacherId as string) && r.campusId === Number(ctx.body?.campusId ?? 0)); if (exists) return fail(3122, '排班冲突:该老师在此校区已存在排班'); return ok({ classId: 9000 + c.id }); }, }, /* 48. GET /api/admin/courses/:id/records —— 课表(排班实例) */ { method: 'GET', path: '/api/admin/courses/:id/records', handler: (ctx) => { const denied = checkScope(ctx); 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, startTime: r.startTime, endTime: r.endTime, status: r.status, studentCount: r.students.length, })); return page(rows, Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10)); }, }, /* 49. POST /api/admin/courses/:id/records/generate —— 生成课表(幂等) */ { method: 'POST', path: '/api/admin/courses/:id/records/generate', handler: (ctx) => { const denied = checkScope(ctx); if (denied) return denied; const c = findCourse(Number(ctx.params.id)); if (!c) return fail(3001, '课程不存在'); const days = Number(ctx.body?.days ?? 14); // 幂等:已生成过同课程未来课表 → 不重复 const generated = c.records.filter((r) => r.status === 0).length; return ok({ generated, days, changed: generated < days }); }, }, /* 50. POST /api/admin/courses/records/:recordId/start —— 开始上课 */ { method: 'POST', path: '/api/admin/courses/records/:recordId/start', handler: (ctx) => { const denied = checkScope(ctx); if (denied) return denied; // 找 record(遍历课程) let rec: CourseRecord | undefined; let courseId = 0; for (const c of queryCourses({})) { const r = c.records.find((x) => x.id === Number(ctx.params.recordId)); if (r) { rec = r; courseId = c.id; break; } } if (!rec) return fail(3001, '排班实例不存在'); if (rec.status !== 0) return fail(3121, '上课状态非法(仅未开始可开始)'); rec.status = 1; return ok({ recordId: rec.id, status: 1, courseId }); }, }, /* 51. POST /api/admin/courses/records/:recordId/finish —— 结束上课(重操作:扣课时+spend+分账+课酬) */ { method: 'POST', path: '/api/admin/courses/records/:recordId/finish', handler: (ctx) => { const denied = checkScope(ctx); if (denied) return denied; let rec: CourseRecord | undefined; let courseId = 0; for (const c of queryCourses({})) { const r = c.records.find((x) => x.id === Number(ctx.params.recordId)); if (r) { rec = r; courseId = c.id; break; } } if (!rec) return fail(3001, '排班实例不存在'); if (rec.status === 2) return fail(3121, '上课状态非法(已结束不可重复结束)'); rec.status = 2; // 逐学生:出勤(type=1)者扣课时,产生分账 + 课酬(v1.6 C3:type/pay/cost/giveCost) let spentTotal = 0; const perStudent = rec.students.map((s) => { if (s.type === 1) { appendLog(s.userId, rec.campusId, 101, -s.cost, '课时代扣(finish)'); spentTotal += s.cost; } return { userId: s.userId, type: s.type, pay: s.paid, // 端点51 perStudent[].pay = bool(Go perStudentView.Pay bool,是否已付;非金额) 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, payroll: { detailId: rec.id * 10, reward: fmt(0) }, perStudent, }); }, }, /* 52. POST /api/admin/courses/records/:recordId/attendance —— 打卡(逐学生) */ { method: 'POST', path: '/api/admin/courses/records/:recordId/attendance', handler: (ctx) => { const denied = checkScope(ctx); if (denied) return denied; let rec: CourseRecord | undefined; for (const c of queryCourses({})) { const r = c.records.find((x) => x.id === Number(ctx.params.recordId)); if (r) rec = r; } if (!rec) return fail(3001, '排班实例不存在'); if (rec.status === 2) return fail(3121, '已结束不可再打卡'); // 端点52 打卡:pay = 是否已付 0/1(Go *int8,nil=不改;非金额,金额是 cost);cost/giveCost = 金额 const list = (ctx.body?.list as Array<{ userId: string; type: number; pay?: number; cost?: string; giveCost?: string }>) ?? []; for (const item of list) { const s = rec.students.find((x) => x.userId === item.userId); if (s) { s.type = item.type; if (item.pay !== undefined) s.paid = item.pay === 1; if (item.cost) s.cost = toCents(item.cost); if (item.giveCost) s.giveCost = toCents(item.giveCost); } } return ok({ recordId: rec.id, checked: list.length }); }, }, /* 53. GET /api/admin/courses/:id/students —— 报课学生(含欠费) */ { method: 'GET', path: '/api/admin/courses/:id/students', handler: (ctx) => { const denied = checkScope(ctx); if (denied) return denied; 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 ? u.realName : null, pay: fmt(s.pay), cost: fmt(s.cost), giveCost: fmt(s.giveCost), remainderReceivable: fmt(20000), // mock 欠费 status: 1, // 数字码(1 在读;中文前端映射) }; }); return page(rows, Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10)); }, }, /* 54. POST /api/admin/courses/:id/students —— 报名/补报名 */ { method: 'POST', path: '/api/admin/courses/:id/students', handler: (ctx) => { const denied = checkScope(ctx); 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]; // 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')), paid: true }); } return ok({ enrolled: true, courseId: c.id, userId }); }, }, /* 55. GET /api/admin/courses/arrears —— 欠费列表(unpaid>0) */ { method: 'GET', path: '/api/admin/courses/arrears', 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'.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)); }, }, /* 56. POST /api/admin/courses/arrears/counteract —— 欠费核销 */ { method: 'POST', path: '/api/admin/courses/arrears/counteract', handler: (ctx) => { const denied = checkScope(ctx); if (denied) return denied; const studentId = ctx.body?.studentId as string; const campusId = Number(ctx.body?.campusId ?? 0); const m = toCents(String(ctx.body?.money ?? '0.00')); if (!studentId || m <= 0) return fail(2001, 'studentId/money 缺失或非法'); appendLog(studentId, campusId, 207, -m, '欠费核销'); return ok({ counteracted: true, studentId, amount: fmt(m) }); }, }, ];