| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350 |
- /**
- * 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> | 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<string, unknown> {
- 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) });
- },
- },
- ];
|