courses.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. /**
  2. * routes/courses.ts —— 课程/排班/打卡端点 mock(academic 契约 §2.4 端点 44~56,PC 侧 13 个;端点 57 属移动端不做)。
  3. *
  4. * 契约对齐(academic-frontend-contract-v1.md v1.8/v1.9):
  5. * - 端点 49 生成课表:同 (courses + start_time) 幂等(已生成不重复);
  6. * - 端点 51 finish 重操作:一次调用产生 扣课时 + 余额变动 + 分账 + 课酬(mock 返回聚合结果 + changed 标记,
  7. * 前端须二次确认 + loading 锁 + 失败刷新整页——交互在页面层,mock 只保证契约返回);
  8. * - 端点 51 `divide[]` = [{userId, type, amount}](v1.7:无 level/quota,分账日志表无层级列);
  9. * - 端点 52 打卡:逐学生 type/pay/cost/giveCost(v1.6 C3);
  10. * - 端点 46/48 无 `room`(v1.8,源列只有 address);`week` 为字符串(v1.8);
  11. * - 端点 53 `realName` 可空(v1.8);欠费字段 = `remainderReceivable`(v1.2/v1.6 A1);
  12. * - 端点 54 报名不写资金流水、不动余额(v1.8);重复报名 → 3143(v1.8);
  13. * - 分支:3121(打卡/上课状态非法)/ 3122(排班冲突)/ 3143(重复报名)。
  14. *
  15. * 本文件仅在 dev + VITE_API_MOCK 下被加载(经 http.ts 动态 import 门控),不进 dist。
  16. */
  17. import { fail, ok, page } from '@/api/mock/envelope';
  18. import { fmt, toCents } from '@/api/mock/money';
  19. import { inScope, findUser, findCourse, queryCourses, appendLog } from '@/api/mock/data';
  20. import type { Course, CourseRecord } from '@/api/mock/data';
  21. import type { MockCtx, MockRoute } from '@/api/mock/adapter';
  22. import type { R } from '@/types/api';
  23. function checkScope(ctx: MockCtx): R<undefined> | undefined {
  24. const institutionId = ctx.query.institutionId !== undefined ? Number(ctx.query.institutionId) : 1;
  25. const campusId = ctx.query.campusId !== undefined ? Number(ctx.query.campusId) : 0;
  26. return inScope(institutionId, campusId) ? undefined : fail(1004, '作用域越界:当前账号无权访问该机构/校区');
  27. }
  28. function courseRow(c: Course): Record<string, unknown> {
  29. return {
  30. id: c.id,
  31. institutionId: c.institutionId,
  32. name: c.name,
  33. classify: c.classify,
  34. status: c.status,
  35. teacherIds: c.teacherIds,
  36. recordCount: c.records.length,
  37. };
  38. }
  39. export const courseRoutes: MockRoute[] = [
  40. /* 44. GET /api/admin/courses —— 课程列表 */
  41. {
  42. method: 'GET',
  43. path: '/api/admin/courses',
  44. handler: (ctx) => {
  45. const denied = checkScope(ctx);
  46. if (denied) return denied;
  47. const rows = queryCourses({
  48. classify: ctx.query.classify,
  49. status: ctx.query.status !== undefined ? Number(ctx.query.status) : undefined,
  50. keyword: ctx.query.keyword,
  51. institutionId: ctx.query.institutionId ? Number(ctx.query.institutionId) : 1,
  52. campusId: ctx.query.campusId ? Number(ctx.query.campusId) : 0,
  53. });
  54. return page(rows.map(courseRow), Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10));
  55. },
  56. },
  57. /* 45a. POST /api/admin/courses —— 新建课程 */
  58. {
  59. method: 'POST',
  60. path: '/api/admin/courses',
  61. handler: (ctx) => {
  62. const denied = checkScope(ctx);
  63. if (denied) return denied;
  64. const name = ctx.body?.name as string | undefined;
  65. if (!name) return fail(2001, 'name 缺失');
  66. return ok({ id: 100 + Math.floor(Math.random() * 900) });
  67. },
  68. },
  69. /* 45b. PUT /api/admin/courses/:id —— 更新课程 */
  70. {
  71. method: 'PUT',
  72. path: '/api/admin/courses/:id',
  73. handler: (ctx) => {
  74. const denied = checkScope(ctx);
  75. if (denied) return denied;
  76. const c = findCourse(Number(ctx.params.id));
  77. if (!c) return fail(3001, '课程不存在');
  78. return ok({ id: c.id });
  79. },
  80. },
  81. /* 46. GET /api/admin/courses/:id/classes —— 排班规则列表 */
  82. {
  83. method: 'GET',
  84. path: '/api/admin/courses/:id/classes',
  85. handler: (ctx) => {
  86. const denied = checkScope(ctx);
  87. if (denied) return denied;
  88. const c = findCourse(Number(ctx.params.id));
  89. if (!c) return fail(3001, '课程不存在');
  90. // v1.8:排班规则行 = {classId, courseId, teacherId, week(字符串), start, end, campusId};无 room(源列只有 address)
  91. const rules = c.records.slice(0, 2).map((r, i) => ({
  92. classId: r.id * 10 + i,
  93. courseId: c.id,
  94. teacherId: r.teacherId,
  95. week: r.week,
  96. start: r.startTime,
  97. end: r.endTime,
  98. campusId: r.campusId,
  99. }));
  100. return page(rules, Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10));
  101. },
  102. },
  103. /* 47. POST /api/admin/courses/:id/classes —— 新建排班规则(3122 冲突) */
  104. {
  105. method: 'POST',
  106. path: '/api/admin/courses/:id/classes',
  107. handler: (ctx) => {
  108. const denied = checkScope(ctx);
  109. if (denied) return denied;
  110. const c = findCourse(Number(ctx.params.id));
  111. if (!c) return fail(3001, '课程不存在');
  112. if (ctx.body?.teacherId === undefined || ctx.body?.week === undefined) return fail(2001, 'teacherId/week 缺失');
  113. // 3122:同老师同周同校区已有规则 → 排班冲突
  114. const exists = c.records.some((r) => r.teacherId === (ctx.body?.teacherId as string) && r.campusId === Number(ctx.body?.campusId ?? 0));
  115. if (exists) return fail(3122, '排班冲突:该老师在此校区已存在排班');
  116. return ok({ classId: 9000 + c.id });
  117. },
  118. },
  119. /* 48. GET /api/admin/courses/:id/records —— 课表(排班实例) */
  120. {
  121. method: 'GET',
  122. path: '/api/admin/courses/:id/records',
  123. handler: (ctx) => {
  124. const denied = checkScope(ctx);
  125. if (denied) return denied;
  126. const c = findCourse(Number(ctx.params.id));
  127. if (!c) return fail(3001, '课程不存在');
  128. // v1.8:课表实例行 = {recordId, courseId, teacherId, campusId, startTime, endTime, status, studentCount};
  129. // 无 room(源列只有 address,地址≠教室号不得顶替)。⚠ 时间窗 `to` 含当日(闭区间,与其他端点右开相反)。
  130. const rows = c.records.map((r) => ({
  131. recordId: r.id,
  132. courseId: c.id,
  133. teacherId: r.teacherId,
  134. campusId: r.campusId,
  135. startTime: r.startTime,
  136. endTime: r.endTime,
  137. status: r.status,
  138. studentCount: r.students.length,
  139. }));
  140. return page(rows, Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10));
  141. },
  142. },
  143. /* 49. POST /api/admin/courses/:id/records/generate —— 生成课表(幂等) */
  144. {
  145. method: 'POST',
  146. path: '/api/admin/courses/:id/records/generate',
  147. handler: (ctx) => {
  148. const denied = checkScope(ctx);
  149. if (denied) return denied;
  150. const c = findCourse(Number(ctx.params.id));
  151. if (!c) return fail(3001, '课程不存在');
  152. const days = Number(ctx.body?.days ?? 14);
  153. // 幂等:已生成过同课程未来课表 → 不重复
  154. const generated = c.records.filter((r) => r.status === 0).length;
  155. return ok({ generated, days, changed: generated < days });
  156. },
  157. },
  158. /* 50. POST /api/admin/courses/records/:recordId/start —— 开始上课 */
  159. {
  160. method: 'POST',
  161. path: '/api/admin/courses/records/:recordId/start',
  162. handler: (ctx) => {
  163. const denied = checkScope(ctx);
  164. if (denied) return denied;
  165. // 找 record(遍历课程)
  166. let rec: CourseRecord | undefined;
  167. let courseId = 0;
  168. for (const c of queryCourses({})) {
  169. const r = c.records.find((x) => x.id === Number(ctx.params.recordId));
  170. if (r) {
  171. rec = r;
  172. courseId = c.id;
  173. break;
  174. }
  175. }
  176. if (!rec) return fail(3001, '排班实例不存在');
  177. if (rec.status !== 0) return fail(3121, '上课状态非法(仅未开始可开始)');
  178. rec.status = 1;
  179. return ok({ recordId: rec.id, status: 1, courseId });
  180. },
  181. },
  182. /* 51. POST /api/admin/courses/records/:recordId/finish —— 结束上课(重操作:扣课时+spend+分账+课酬) */
  183. {
  184. method: 'POST',
  185. path: '/api/admin/courses/records/:recordId/finish',
  186. handler: (ctx) => {
  187. const denied = checkScope(ctx);
  188. if (denied) return denied;
  189. let rec: CourseRecord | undefined;
  190. let courseId = 0;
  191. for (const c of queryCourses({})) {
  192. const r = c.records.find((x) => x.id === Number(ctx.params.recordId));
  193. if (r) {
  194. rec = r;
  195. courseId = c.id;
  196. break;
  197. }
  198. }
  199. if (!rec) return fail(3001, '排班实例不存在');
  200. if (rec.status === 2) return fail(3121, '上课状态非法(已结束不可重复结束)');
  201. rec.status = 2;
  202. // 逐学生:出勤(type=1)者扣课时,产生分账 + 课酬(v1.6 C3:type/pay/cost/giveCost)
  203. let spentTotal = 0;
  204. const perStudent = rec.students.map((s) => {
  205. if (s.type === 1) {
  206. appendLog(s.userId, rec.campusId, 101, -s.cost, '课时代扣(finish)');
  207. spentTotal += s.cost;
  208. }
  209. return {
  210. userId: s.userId,
  211. type: s.type,
  212. pay: s.paid, // 端点51 perStudent[].pay = bool(Go perStudentView.Pay bool,是否已付;非金额)
  213. cost: fmt(s.cost),
  214. giveCost: fmt(s.giveCost),
  215. counteracted: false,
  216. };
  217. });
  218. // v1.7:divide[] = [{userId, type, amount}](分账日志表无层级列,不下发 level/quota;type=分成类型 1一次付清/2按课次)
  219. const divide = rec.teacherId
  220. ? [{ userId: rec.teacherId, type: 1, amount: fmt(Math.round(spentTotal * 0.3)) }]
  221. : [];
  222. return ok({
  223. recordId: rec.id,
  224. courseId,
  225. status: 2,
  226. changed: true,
  227. totalSpend: fmt(spentTotal),
  228. divide,
  229. payroll: { detailId: rec.id * 10, reward: fmt(0) },
  230. perStudent,
  231. });
  232. },
  233. },
  234. /* 52. POST /api/admin/courses/records/:recordId/attendance —— 打卡(逐学生) */
  235. {
  236. method: 'POST',
  237. path: '/api/admin/courses/records/:recordId/attendance',
  238. handler: (ctx) => {
  239. const denied = checkScope(ctx);
  240. if (denied) return denied;
  241. let rec: CourseRecord | undefined;
  242. for (const c of queryCourses({})) {
  243. const r = c.records.find((x) => x.id === Number(ctx.params.recordId));
  244. if (r) rec = r;
  245. }
  246. if (!rec) return fail(3001, '排班实例不存在');
  247. if (rec.status === 2) return fail(3121, '已结束不可再打卡');
  248. // 端点52 打卡:pay = 是否已付 0/1(Go *int8,nil=不改;非金额,金额是 cost);cost/giveCost = 金额
  249. const list = (ctx.body?.list as Array<{ userId: string; type: number; pay?: number; cost?: string; giveCost?: string }>) ?? [];
  250. for (const item of list) {
  251. const s = rec.students.find((x) => x.userId === item.userId);
  252. if (s) {
  253. s.type = item.type;
  254. if (item.pay !== undefined) s.paid = item.pay === 1;
  255. if (item.cost) s.cost = toCents(item.cost);
  256. if (item.giveCost) s.giveCost = toCents(item.giveCost);
  257. }
  258. }
  259. return ok({ recordId: rec.id, checked: list.length });
  260. },
  261. },
  262. /* 53. GET /api/admin/courses/:id/students —— 报课学生(含欠费) */
  263. {
  264. method: 'GET',
  265. path: '/api/admin/courses/:id/students',
  266. handler: (ctx) => {
  267. const denied = checkScope(ctx);
  268. if (denied) return denied;
  269. const c = findCourse(Number(ctx.params.id));
  270. if (!c) return fail(3001, '课程不存在');
  271. const rec = c.records[0];
  272. // v1.6 C3/A1 + v1.8:行 = {userId, realName(可空), pay, cost, giveCost, remainderReceivable, status(数字码)}。
  273. // realName 查不到给 null(区分"没这个人"与"名字为空");欠费用 remainderReceivable(unpaid 是 2018 停更僵尸列)。
  274. const rows = (rec?.students ?? []).map((s) => {
  275. const u = findUser(s.userId);
  276. return {
  277. userId: s.userId,
  278. realName: u ? u.realName : null,
  279. pay: fmt(s.pay),
  280. cost: fmt(s.cost),
  281. giveCost: fmt(s.giveCost),
  282. remainderReceivable: fmt(20000), // mock 欠费
  283. status: 1, // 数字码(1 在读;中文前端映射)
  284. };
  285. });
  286. return page(rows, Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10));
  287. },
  288. },
  289. /* 54. POST /api/admin/courses/:id/students —— 报名/补报名 */
  290. {
  291. method: 'POST',
  292. path: '/api/admin/courses/:id/students',
  293. handler: (ctx) => {
  294. const denied = checkScope(ctx);
  295. if (denied) return denied;
  296. const c = findCourse(Number(ctx.params.id));
  297. if (!c) return fail(3001, '课程不存在');
  298. // v1.8 补登入参:userId(必填) / pay(必填,金额) / lessonCost(必填,金额) / number(必填,课次);可选 giveNumber/discountCash/giveCost/classId/campusId/recommenderUserId/remark
  299. const userId = ctx.body?.userId as string;
  300. const pay = toCents(String(ctx.body?.pay ?? '0.00'));
  301. const lessonCost = toCents(String(ctx.body?.lessonCost ?? '0.00'));
  302. const number = Number(ctx.body?.number ?? 0);
  303. if (!userId || !findUser(userId)) return fail(2001, 'userId 缺失或用户不存在');
  304. if (pay <= 0 || lessonCost <= 0 || number <= 0) return fail(2001, 'pay/lessonCost/number 必填且须 >0');
  305. const rec = c.records[0];
  306. // v1.8:重复报名 → 3143(专用码;3140 是"参数互相矛盾",不混用)
  307. if (rec && rec.students.some((s) => s.userId === userId)) return fail(3143, '该学生已报名此课程');
  308. // v1.8:⚠ 不写资金流水、不动余额——报课行只是应收/实收快照,真实收款走财务端点 3(充值)/ 4(消费)。
  309. if (rec) {
  310. rec.students.push({ userId, type: 0, pay, cost: lessonCost, giveCost: toCents(String(ctx.body?.giveCost ?? '0.00')), paid: true });
  311. }
  312. return ok({ enrolled: true, courseId: c.id, userId });
  313. },
  314. },
  315. /* 55. GET /api/admin/courses/arrears —— 欠费列表(unpaid>0) */
  316. {
  317. method: 'GET',
  318. path: '/api/admin/courses/arrears',
  319. handler: (ctx) => {
  320. const denied = checkScope(ctx);
  321. if (denied) return denied;
  322. // v1.6 A1:欠费字段 = remainderReceivable(unpaid 是 2018-05 停更僵尸列,禁展示/禁返回)。
  323. // v1.8:counteracted 判据 = net_receipts > 0(该行发生过实收/核销)。
  324. const rows = [
  325. { studentId: '01J2M' + '201'.padStart(21, '0'), courseId: 1, remainderReceivable: fmt(20000), counteracted: false },
  326. { studentId: '01J2M' + '202'.padStart(21, '0'), courseId: 1, remainderReceivable: fmt(12000), counteracted: false },
  327. { studentId: '01J2M' + '204'.padStart(21, '0'), courseId: 2, remainderReceivable: fmt(8000), counteracted: true },
  328. ];
  329. return page(rows, Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10));
  330. },
  331. },
  332. /* 56. POST /api/admin/courses/arrears/counteract —— 欠费核销 */
  333. {
  334. method: 'POST',
  335. path: '/api/admin/courses/arrears/counteract',
  336. handler: (ctx) => {
  337. const denied = checkScope(ctx);
  338. if (denied) return denied;
  339. const studentId = ctx.body?.studentId as string;
  340. const campusId = Number(ctx.body?.campusId ?? 0);
  341. const m = toCents(String(ctx.body?.money ?? '0.00'));
  342. if (!studentId || m <= 0) return fail(2001, 'studentId/money 缺失或非法');
  343. appendLog(studentId, campusId, 207, -m, '欠费核销');
  344. return ok({ counteracted: true, studentId, amount: fmt(m) });
  345. },
  346. },
  347. ];