Browse Source

课程域 04 三 P0 + 05 C-5 按 Go 权威对齐

P0-1 端点47 字段名 teacher→teacherId(Go dto_course CreateClassReq.TeacherID json:/teacherId/):DTO/视图/mock 三方同步
P0-2 端点52 pay 由金额改为是否已付 0/1(Go AttendanceItemReq.Pay *int8;金额是 cost):mock 52 写 paid 布尔、视图打卡对话框"课程费用"列改"是否已付"开关
P0-3 端点51 perStudent.pay 由金额字符串改为 bool(Go perStudentView.Pay bool):mock 51 下发 s.paid、DTO PerStudentItem.pay boolean
C-5 端点60c 删 createUserId 入参(Go 取当前登录用户记账,防伪造):payroll mock 恒取 super_admin、finance DTO 去字段、expenditure 视图删记账人下拉
data.ts 引入独立 paid 布尔(端点52/51 是否已付 与 端点53 应收金额 pay 是两个概念,不复用同字段)

门禁四件全绿:vue-tsc 0 错 / eslint 0 错 / vite build 成功 / dist 无 mock 逻辑串
Q 12 hours ago
parent
commit
61ea48d489

+ 7 - 7
src/api/academic/course.ts

@@ -87,10 +87,10 @@ export function listClasses(
   return http.get<PageR<CourseClassDTO>>(`/api/admin/courses/${courseId}/classes`, { params }).then((r) => r.data);
 }
 
-/** 新建排班规则入参(端点 47;契约 §2.4:teacher/week/start/end/campus)。⚠ 老师字段名 = `teacher`(契约/mock 口径,非 teacherId)。 */
+/** 新建排班规则入参(端点 47;Go DTO `CreateClassReq`:teacherId/week/start/end/campus)。老师字段名 = `teacherId`(Go 权威,非 teacher)。 */
 export type CreateClassBody = {
-  /** 授课老师 id(canonical ULID;字段名 teacher) */
-  teacher: string;
+  /** 授课老师 id(canonical ULID;字段名 teacherId;可空 = 未定老师) */
+  teacherId: string;
   /** 周(原值字符串,同 CourseClassDTO.week) */
   week: string;
   /** 上课起时间(RFC3339) */
@@ -194,8 +194,8 @@ export interface PerStudentItem {
   userId: string;
   /** 上课类型(1 正常 / 2 插班 / 3 赠送) */
   type: number;
-  /** 本次课程费用(字符串两位小数) */
-  pay: string;
+  /** 是否已支付(端点 51 perStudent[].pay = bool;Go `perStudentView.Pay bool`,**非金额**——金额是 cost) */
+  pay: boolean;
   /** 本次课时费用(字符串两位小数) */
   cost: string;
   /** 本次赠送金额(字符串两位小数) */
@@ -240,8 +240,8 @@ export interface AttendanceItem {
   userId: string;
   /** 上课类型(1 正常 / 2 插班 / 3 赠送) */
   type: number;
-  /** 本次课程费用(字符串两位小数;可空 = 不改) */
-  pay?: string;
+  /** 是否已付(0 未付 / 1 已付;可空 = 不改,结课时按"已报课即已付";Go `Pay *int8`,**非金额**——金额是 cost) */
+  pay?: number;
   /** 本次课时费用(字符串两位小数;可空 = 不改) */
   cost?: string;
   /** 本次赠送金额(字符串两位小数;可空 = 不改) */

+ 2 - 2
src/api/academic/finance.ts

@@ -347,12 +347,12 @@ export function fetchExpenditureTypes(params: { institutionId: number; campusId?
 }
 
 /** 新增收支记录(端点 60,POST /api/admin/expenditures)→ {id, bizNo}。
- *  expenditureId 指向字典(type/name 由后端 JOIN 字典得出,前端不传);createUserId 可空。 */
+ *  expenditureId 指向字典(type/name 由后端 JOIN 字典得出,前端不传)。
+ *  ⚠ 记账人(createUserId)由**后端取当前登录用户**,前端不传(防伪造记账人;Go 端点 60 不接受该入参)。 */
 export function createExpenditure(body: {
   expenditureId: number;
   money: string;
   campusId: number;
-  createUserId?: string | null;
   institutionId: number;
 }): Promise<R<{ id: number; bizNo: string }>> {
   return http.post<R<{ id: number; bizNo: string }>>('/api/admin/expenditures', body).then((r) => r.data);

+ 7 - 5
src/api/mock/data.ts

@@ -449,8 +449,10 @@ export interface CourseRecord {
   startTime: string; // RFC3339
   endTime: string;
   status: 0 | 1 | 2; // 0 未开始 / 1 进行中 / 2 已结束
-  /** 逐学生(C3:`attendance/payCost`→`type/pay/cost/giveCost`) */
-  students: { userId: string; type: number; pay: number; cost: number; giveCost: number }[];
+  /** 逐学生(C3:`attendance/payCost`→`type/paid/cost/giveCost`)。
+   *  ⚠ `pay`=应收金额(端点53 口径,保留);`paid`=是否已付(端点52 请求 0/1、端点51 perStudent bool 的布尔源,
+   *     与 `pay` 金额是两个概念,不可复用同一字段)。 */
+  students: { userId: string; type: number; pay: number; cost: number; giveCost: number; paid: boolean }[];
 }
 export interface Course {
   id: number;
@@ -476,9 +478,9 @@ function mkRecord(courseId: number, teacherId: string, campusId: number, address
     endTime: rfc3339(start + 90 * 60_000),
     status,
     students: [
-      { userId: ulid(201), type: present(true), pay: 10000, cost: 10000, giveCost: 0 },
-      { userId: ulid(202), type: present(true), pay: 10000, cost: 10000, giveCost: 0 },
-      { userId: ulid(204), type: present(false), pay: 10000, cost: 10000, giveCost: 0 },
+      { userId: ulid(201), type: present(true), pay: 10000, cost: 10000, giveCost: 0, paid: true },
+      { userId: ulid(202), type: present(true), pay: 10000, cost: 10000, giveCost: 0, paid: true },
+      { userId: ulid(204), type: present(false), pay: 10000, cost: 10000, giveCost: 0, paid: true },
     ],
   };
 }

+ 7 - 7
src/api/mock/routes/courses.ts

@@ -112,9 +112,9 @@ export const courseRoutes: MockRoute[] = [
       if (denied) return denied;
       const c = findCourse(Number(ctx.params.id));
       if (!c) return fail(3001, '课程不存在');
-      if (ctx.body?.teacher === undefined || ctx.body?.week === undefined) return fail(2001, 'teacher/week 缺失');
+      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?.teacher as string) && r.campusId === Number(ctx.body?.campusId ?? 0));
+      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 });
     },
@@ -212,7 +212,7 @@ export const courseRoutes: MockRoute[] = [
         return {
           userId: s.userId,
           type: s.type,
-          pay: fmt(s.pay),
+          pay: s.paid, // 端点51 perStudent[].pay = bool(Go perStudentView.Pay bool,是否已付;非金额)
           cost: fmt(s.cost),
           giveCost: fmt(s.giveCost),
           counteracted: false,
@@ -248,13 +248,13 @@ export const courseRoutes: MockRoute[] = [
       }
       if (!rec) return fail(3001, '排班实例不存在');
       if (rec.status === 2) return fail(3121, '已结束不可再打卡');
-      // v1.6 C3:打卡请求逐学生字段 = type/pay/cost/giveCost(原 attendance/payCost 作废)
-      const list = (ctx.body?.list as Array<{ userId: string; type: number; pay?: string; cost?: string; giveCost?: string }>) ?? [];
+      // 端点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) s.pay = toCents(item.pay);
+          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);
         }
@@ -310,7 +310,7 @@ export const courseRoutes: MockRoute[] = [
       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')) });
+        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 });
     },

+ 6 - 10
src/api/mock/routes/payroll.ts

@@ -23,7 +23,7 @@
 import { fail, ok, page } from '@/api/mock/envelope';
 import { fmt, toCents } from '@/api/mock/money';
 import { rfc3339 } from '@/api/mock/time';
-import { inScope, findUser, queryPayrollPlans, expenditureTypes, expenditureTypeOf } from '@/api/mock/data';
+import { inScope, findUser, users, queryPayrollPlans, expenditureTypes, expenditureTypeOf } from '@/api/mock/data';
 import type { PayrollDetail, PayrollPlan } from '@/api/mock/data';
 import type { MockCtx, MockRoute } from '@/api/mock/adapter';
 import type { R } from '@/types/api';
@@ -256,7 +256,7 @@ export const payrollRoutes: MockRoute[] = [
       return ok({ types: expenditureTypes.map((t) => ({ id: t.id, type: t.type, name: t.name, parentId: t.parentId })) });
     },
   },
-  /* 60c. POST /api/admin/expenditures —— 新增收支记录(v1.9:expenditureId 回填字典;createUserId 可空) */
+  /* 60c. POST /api/admin/expenditures —— 新增收支记录(v1.9:expenditureId 回填字典;⚠ 记账人恒取当前登录用户,不接受前端 createUserId) */
   {
     method: 'POST',
     path: '/api/admin/expenditures',
@@ -268,20 +268,16 @@ export const payrollRoutes: MockRoute[] = [
       if (!dict) return fail(2001, 'expenditureId 缺失或不在收支字典内');
       const money = toCents(String(ctx.body?.money ?? '0.00'));
       if (money <= 0) return fail(2002, '金额须大于 0');
-      // createUserId = 记账人(可空);传了则校验存在
-      const rawCreateUserId = ctx.body?.createUserId;
-      let createUserId: string | null = null;
-      if (typeof rawCreateUserId === 'string' && rawCreateUserId) {
-        if (!findUser(rawCreateUserId)) return fail(2001, 'createUserId 用户不存在');
-        createUserId = rawCreateUserId;
-      }
+      // ⚠ 记账人 = 当前登录用户(Go 端点 60 取当前登录用户,**不接受前端 createUserId**——防伪造记账人)。
+      //    mock 登录态恒超管,故取 super_admin 用户;忽略 ctx.body.createUserId。
+      const current = users.find((u) => u.role === 'super_admin') ?? users[0];
       const e: Expenditure = {
         id: ++expSeq,
         expenditureId: dict.id,
         type: dict.type, // type/name 由字典 JOIN 得出(记录表无这两列)
         name: dict.name,
         money,
-        createUserId,
+        createUserId: current.userId,
         campusId: Number(ctx.body?.campusId ?? 0),
         bizNo: `EXP${expSeq}`,
         createTime: rfc3339(Date.now()),

+ 5 - 19
src/views/academic/expenditure/index.vue

@@ -9,15 +9,13 @@
  *
  * v1.9 三条修正(本页面直接体现):
  *   - 用 `type` 判方向(1 支出 / 2 收入),**没有 direction 列**;
- *   - `createUserId` = **记账人**(← create_user_id,可空),不是"收付对象";
+ *   - `createUserId` = **记账人**(← create_user_id,不是"收付对象");**新增时由后端取当前登录用户**,前端不传;
  *   - `expenditureId` 回填字典,编辑表单可定位类型。
  */
 import { listExpenditures, fetchExpenditureTypes, createExpenditure } from '@/api/academic/finance';
 import type { ExpenditureRecordDTO, ExpenditureTypeDTO } from '@/api/academic/finance';
 import { listCampuses } from '@/api/org/campus';
 import type { CampusDTO } from '@/api/org/campus';
-import { listMembers } from '@/api/org/member';
-import type { MemberDTO } from '@/api/org/member';
 import ListPage from '@/components/ListPage.vue';
 import type { ListColumn } from '@/components/list-column';
 import { useListPage } from '@/composables/use-list-page';
@@ -33,19 +31,13 @@ function ElMessage_(type: 'success' | 'warning' | 'error', text: string): void {
   else console.warn(`[${type}]`, text);
 }
 
-/* ================= 选项数据(校区/记账人) ================= */
+/* ================= 选项数据(校区;记账人恒取当前登录账号,不再需要成员下拉) ================= */
 const campusOpts = ref<CampusDTO[]>([]);
-const memberOpts = ref<MemberDTO[]>([]);
 const campusName = (id: number): string => campusOpts.value.find((c) => c.campusId === id)?.campusName ?? `#${id}`;
 
 onMounted(async () => {
-  const q = scope.toQuery();
-  const [c, m] = await Promise.all([
-    listCampuses({ ...q, pageSize: 200 }),
-    listMembers({ ...q, pageSize: 200 }),
-  ]);
+  const c = await listCampuses({ ...scope.toQuery(), pageSize: 200 });
   if (c.code === 200) campusOpts.value = c.data?.list ?? [];
-  if (m.code === 200) memberOpts.value = m.data?.list ?? [];
 });
 
 /* ================= 收支记录(端点 60 GET) ================= */
@@ -87,12 +79,11 @@ onMounted(() => {
 const addVisible = ref(false);
 const addBusy = ref(false);
 const selectedCampusId = ref<number>(0);
-const addForm = reactive({ expenditureId: 0, money: '', createUserId: '' });
+const addForm = reactive({ expenditureId: 0, money: '' });
 
 function openAdd(): void {
   addForm.expenditureId = 0;
   addForm.money = '';
-  addForm.createUserId = '';
   selectedCampusId.value = scope.current.campusId || 0;
   addVisible.value = true;
 }
@@ -111,7 +102,6 @@ async function doAdd(): Promise<void> {
       expenditureId: addForm.expenditureId,
       money: addForm.money,
       campusId: selectedCampusId.value,
-      createUserId: addForm.createUserId || null,
       institutionId: scope.current.institutionId,
     });
     if (r.code === 200) {
@@ -182,11 +172,7 @@ async function doAdd(): Promise<void> {
             <el-option v-for="c in campusOpts" :key="c.campusId" :value="c.campusId" :label="c.campusName" />
           </el-select>
         </el-form-item>
-        <el-form-item label="记账人">
-          <el-select v-model="addForm.createUserId" clearable placeholder="可空" style="width: 100%">
-            <el-option v-for="m in memberOpts" :key="m.userId" :value="m.userId" :label="m.realName" />
-          </el-select>
-        </el-form-item>
+        <el-alert title="记账人自动取当前登录账号(后端定),无需手动指定" type="info" :closable="false" />
       </el-form>
       <template #footer>
         <el-button @click="addVisible = false">取消</el-button>

+ 14 - 12
src/views/academic/schedule/index.vue

@@ -161,7 +161,7 @@ watch(selectedCourseId, () => {
 const classVisible = ref(false);
 const classBusy = ref(false);
 const classForm = reactive({
-  teacher: '',
+  teacherId: '',
   /** 周多选(-1 全周 或 0..6 子集;提交时拼逗号字符串;-1 与具体周互斥) */
   weeks: [] as number[],
   start: '',
@@ -169,7 +169,7 @@ const classForm = reactive({
   campusId: 0,
 });
 function openClass(): void {
-  classForm.teacher = staffOpts.value[0]?.userId ?? '';
+  classForm.teacherId = staffOpts.value[0]?.userId ?? '';
   classForm.weeks = [];
   classForm.start = '';
   classForm.end = '';
@@ -202,7 +202,7 @@ function toggleWeek(v: number): void {
 }
 async function doCreateClass(): Promise<void> {
   if (!selectedCourseId.value) return;
-  if (!classForm.teacher || !weeksToString() || !classForm.start || !classForm.end) {
+  if (!classForm.teacherId || !weeksToString() || !classForm.start || !classForm.end) {
     ElMessage_('warning', '请填全老师/周/上课起止时间');
     return;
   }
@@ -213,7 +213,7 @@ async function doCreateClass(): Promise<void> {
   classBusy.value = true;
   try {
     const r = await createClass(selectedCourseId.value, {
-      teacher: classForm.teacher,
+      teacherId: classForm.teacherId,
       week: weeksToString(),
       // 本地日期 + 时分秒 → RFC3339(无时区后缀 = 本地时间,与后端 dev 环境口径一致)
       start: `${todayStr()}T${classForm.start}`,
@@ -364,7 +364,8 @@ interface AttRow {
   name: string;
   /** 上课类型(1 正常 / 2 插班 / 3 赠送) */
   type: number;
-  pay: number;
+  /** 是否已付(端点52 pay 0/1 源;端点53 不返回该值,初值按"已报课即已付"= true) */
+  pay: boolean;
   cost: number;
   giveCost: number;
 }
@@ -385,8 +386,9 @@ async function openAttendance(rec: CourseRecordDTO): Promise<void> {
     userId: s.userId,
     name: s.realName ?? s.userId,
     type: 1,
-    // 金额字符串两位小数 → number(el-input-number 绑定;提交时 formatMoney 回字符串)
-    pay: Number(s.pay) || 0,
+    // ⚠ 端点52 pay = 是否已付(0/1),端点53 不返回该值;初值按 Go 默认"已报课即已付"= true,打卡时可改 false(未付)
+    pay: true,
+    // 金额字段(cost/giveCost):端点53 金额字符串两位小数 → number(el-input-number 绑定;提交时 moneyPlain 回字符串)
     cost: Number(s.cost) || 0,
     giveCost: Number(s.giveCost) || 0,
   }));
@@ -394,12 +396,12 @@ async function openAttendance(rec: CourseRecordDTO): Promise<void> {
 async function doAttendance(): Promise<void> {
   const rec = attRecord.value;
   if (!rec) return;
-  // number → 金额字符串两位小数(契约:金额字符串两位小数;提交位用 moneyPlain 不带千分位;0/undefined 不下发 = 不改)
+  // cost/giveCost → 金额字符串两位小数(moneyPlain 不带千分位;0/undefined 不下发 = 不改);pay = 是否已付 0/1(恒下发)
   const toMoney = (v: number | null | undefined): string | undefined => (v && v > 0 ? moneyPlain(v) : undefined);
   const list: AttendanceItem[] = attRows.value.map(({ userId, type, pay, cost, giveCost }) => ({
     userId,
     type,
-    ...(toMoney(pay) ? { pay: toMoney(pay)! } : {}),
+    pay: pay ? 1 : 0,
     ...(toMoney(cost) ? { cost: toMoney(cost)! } : {}),
     ...(toMoney(giveCost) ? { giveCost: toMoney(giveCost)! } : {}),
   }));
@@ -744,7 +746,7 @@ async function doCounteract(): Promise<void> {
           <span>{{ selectedCourse?.name }}</span>
         </el-form-item>
         <el-form-item label="授课老师">
-          <el-select v-model="classForm.teacher" filterable placeholder="选择老师" style="width: 100%">
+          <el-select v-model="classForm.teacherId" filterable placeholder="选择老师" style="width: 100%">
             <el-option v-for="m in staffOpts" :key="m.userId" :value="m.userId" :label="`${m.realName}(${m.phone})`" />
           </el-select>
         </el-form-item>
@@ -809,9 +811,9 @@ async function doCounteract(): Promise<void> {
             </el-select>
           </template>
         </el-table-column>
-        <el-table-column label="课程费用" width="130">
+        <el-table-column label="是否已付" width="100">
           <template #default="{ row }">
-            <el-input-number v-model="row.pay" size="small" :min="0" :precision="2" :step="100" controls-position="right" style="width: 100%" />
+            <el-switch v-model="row.pay" size="small" />
           </template>
         </el-table-column>
         <el-table-column label="课时费用" width="130">