diff --git a/apps/admin/src/pages/Classrooms/classroom-fields.integration.test.ts b/apps/admin/src/pages/Classrooms/classroom-fields.integration.test.ts new file mode 100644 index 0000000..f3843e5 --- /dev/null +++ b/apps/admin/src/pages/Classrooms/classroom-fields.integration.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { CLASSROOM_VISIBLE_FIELDS } from './classroom-fields'; + +describe('classroom visible fields', () => { + it('excludes obsolete course and supervisor metadata', () => { + expect(CLASSROOM_VISIBLE_FIELDS).toEqual([ + 'name', + 'building', + 'floor', + 'roomType', + 'capacity', + 'status', + 'notes', + ]); + }); +}); diff --git a/apps/admin/src/pages/Classrooms/classroom-fields.ts b/apps/admin/src/pages/Classrooms/classroom-fields.ts new file mode 100644 index 0000000..43fe469 --- /dev/null +++ b/apps/admin/src/pages/Classrooms/classroom-fields.ts @@ -0,0 +1,9 @@ +export const CLASSROOM_VISIBLE_FIELDS = [ + 'name', + 'building', + 'floor', + 'roomType', + 'capacity', + 'status', + 'notes', +] as const; diff --git a/apps/admin/src/pages/Classrooms/index.tsx b/apps/admin/src/pages/Classrooms/index.tsx index 8e67ccd..e3e9fe8 100644 --- a/apps/admin/src/pages/Classrooms/index.tsx +++ b/apps/admin/src/pages/Classrooms/index.tsx @@ -154,8 +154,6 @@ const ClassroomsPage: React.FC = () => { render: (v: string) => {v || '-'}, }, { title: '容量', dataIndex: 'capacity', width: 80 }, - { title: '课程类型', dataIndex: 'courseType', width: 100, render: (v: string) => v || '-' }, - { title: '负责人', dataIndex: 'supervisor', width: 100, render: (v: string) => v || '-' }, { title: '状态', width: 100, dataIndex: 'status', @@ -325,12 +323,6 @@ const ClassroomsPage: React.FC = () => { - - - - - - diff --git a/apps/server/src/classroom-rentals/classroom-rentals.service.ts b/apps/server/src/classroom-rentals/classroom-rentals.service.ts index 26f1510..54be700 100644 --- a/apps/server/src/classroom-rentals/classroom-rentals.service.ts +++ b/apps/server/src/classroom-rentals/classroom-rentals.service.ts @@ -546,7 +546,6 @@ export class ClassroomRentalsService { floor: c.floor, roomType: c.roomType, capacity: c.capacity, - supervisor: c.supervisor, })), organizations: Array.from(organizationMap.values()), matrix, diff --git a/apps/server/src/classrooms/classroom-template.spec.ts b/apps/server/src/classrooms/classroom-template.spec.ts new file mode 100644 index 0000000..0444342 --- /dev/null +++ b/apps/server/src/classrooms/classroom-template.spec.ts @@ -0,0 +1,7 @@ +import { CLASSROOM_TEMPLATE_HEADERS } from './classroom-template'; + +describe('classroom import template', () => { + it('contains only classroom fields used by the product', () => { + expect(CLASSROOM_TEMPLATE_HEADERS).toEqual(['教室名', '楼栋', '楼层', '类型', '容量']); + }); +}); diff --git a/apps/server/src/classrooms/classroom-template.ts b/apps/server/src/classrooms/classroom-template.ts new file mode 100644 index 0000000..f4cc29d --- /dev/null +++ b/apps/server/src/classrooms/classroom-template.ts @@ -0,0 +1,9 @@ +export const CLASSROOM_TEMPLATE_COLUMNS = [ + { header: '教室名', key: 'name', width: 15 }, + { header: '楼栋', key: 'building', width: 12 }, + { header: '楼层', key: 'floor', width: 8 }, + { header: '类型', key: 'roomType', width: 10 }, + { header: '容量', key: 'capacity', width: 10 }, +]; + +export const CLASSROOM_TEMPLATE_HEADERS = CLASSROOM_TEMPLATE_COLUMNS.map(({ header }) => header); diff --git a/apps/server/src/classrooms/classrooms.controller.ts b/apps/server/src/classrooms/classrooms.controller.ts index da3aca4..1422ef9 100644 --- a/apps/server/src/classrooms/classrooms.controller.ts +++ b/apps/server/src/classrooms/classrooms.controller.ts @@ -22,6 +22,7 @@ import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { extractRequestInfo } from '../common/request-utils'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import * as ExcelJS from 'exceljs'; +import { CLASSROOM_TEMPLATE_COLUMNS } from './classroom-template'; @UseGuards(JwtAuthGuard) @Controller('classrooms') @@ -50,15 +51,7 @@ export class ClassroomsController { async downloadTemplate(@Res() res: Response) { const workbook = new ExcelJS.Workbook(); const ws = workbook.addWorksheet('教室导入模板'); - ws.columns = [ - { header: '教室名', key: 'name', width: 15 }, - { header: '楼栋', key: 'building', width: 12 }, - { header: '楼层', key: 'floor', width: 8 }, - { header: '类型', key: 'roomType', width: 10 }, - { header: '容量', key: 'capacity', width: 10 }, - { header: '课程类型', key: 'courseType', width: 16 }, - { header: '负责人', key: 'supervisor', width: 12 }, - ]; + ws.columns = CLASSROOM_TEMPLATE_COLUMNS; ws.getRow(1).font = { bold: true }; ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } }; ws.addRow({ @@ -67,8 +60,6 @@ export class ClassroomsController { floor: 2, roomType: '大', capacity: 60, - courseType: '尊享培优班', - supervisor: '张老师', }); ws.addRow({ name: 'B301', @@ -76,8 +67,6 @@ export class ClassroomsController { floor: 3, roomType: '次大', capacity: 40, - courseType: '专业课集训班', - supervisor: '李老师', }); ws.addRow({ name: 'B405', @@ -85,8 +74,6 @@ export class ClassroomsController { floor: 4, roomType: '小', capacity: 20, - courseType: '', - supervisor: '', }); // 说明sheet @@ -97,8 +84,6 @@ export class ClassroomsController { '1. 教室名必填,建议采用「楼栋+房号」如 A201、B301', '2. 类型可填 大 / 次大 / 小,为空默认「大」', '3. 同名教室会自动跳过(不覆盖)', - '4. 课程类型可填尊享培优班、专业课集训班等产品班级', - '5. 负责人为班主任/对接人', ].forEach((note) => ws2.addRow({ note })); res.setHeader( @@ -207,8 +192,6 @@ export class ClassroomsController { floor: Number(row.getCell(3).value) || undefined, roomType: String(row.getCell(4).value || '') || undefined, capacity: Number(row.getCell(5).value) || undefined, - courseType: String(row.getCell(6).value || '') || undefined, - supervisor: String(row.getCell(7).value || '') || undefined, }); }); const result = await this.service.batchImport(rows); diff --git a/apps/server/src/classrooms/classrooms.service.ts b/apps/server/src/classrooms/classrooms.service.ts index d99142e..c379914 100644 --- a/apps/server/src/classrooms/classrooms.service.ts +++ b/apps/server/src/classrooms/classrooms.service.ts @@ -130,7 +130,6 @@ export class ClassroomsService { floor?: number; capacity?: number; roomType?: string; - courseType?: string; }[], ) { let imported = 0; diff --git a/apps/server/src/classrooms/dto/classroom.dto.ts b/apps/server/src/classrooms/dto/classroom.dto.ts index 703470e..6f65bb3 100644 --- a/apps/server/src/classrooms/dto/classroom.dto.ts +++ b/apps/server/src/classrooms/dto/classroom.dto.ts @@ -21,13 +21,6 @@ export class CreateClassroomDto { @IsString() roomType?: string; // 大 / 次大 / 小 - @IsOptional() - @IsString() - courseType?: string; - - @IsOptional() - @IsString() - supervisor?: string; @IsOptional() @IsString() @@ -56,13 +49,6 @@ export class UpdateClassroomDto { @IsString() roomType?: string; - @IsOptional() - @IsString() - courseType?: string; - - @IsOptional() - @IsString() - supervisor?: string; @IsOptional() @IsString() diff --git a/apps/server/src/database/database-migrations.service.ts b/apps/server/src/database/database-migrations.service.ts index 5d4b691..b40d799 100644 --- a/apps/server/src/database/database-migrations.service.ts +++ b/apps/server/src/database/database-migrations.service.ts @@ -14,6 +14,26 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap { await this.backfillOrganizations(); await this.normalizeClassDates(); await this.protectAttendanceHistory(); + await this.removeUnusedClassroomColumns(); + } + + private async removeUnusedClassroomColumns(): Promise { + const runner = this.dataSource.createQueryRunner(); + await runner.connect(); + try { + const tables = await runner.getTables(['classrooms']); + if (tables.length === 0) return; + + const table = await runner.getTable('classrooms'); + const columnNames = new Set(table?.columns.map((column) => column.name) ?? []); + for (const columnName of ['course_type', 'supervisor']) { + if (columnNames.has(columnName)) { + await runner.query(`ALTER TABLE classrooms DROP COLUMN ${columnName}`); + } + } + } finally { + await runner.release(); + } } private async ensureAiConfigTable(): Promise { diff --git a/apps/server/src/database/database-migrations.spec.ts b/apps/server/src/database/database-migrations.spec.ts index c0fee6e..6000976 100644 --- a/apps/server/src/database/database-migrations.spec.ts +++ b/apps/server/src/database/database-migrations.spec.ts @@ -10,6 +10,13 @@ interface MockTable { name: string; columns: MockColumn[]; } +interface MockRunner { + release: jest.Mock; + connect: jest.Mock; + query: jest.Mock; + getTables: jest.Mock; + getTable: jest.Mock; +} function mockRunner(overrides: { getTables?: MockTable[]; @@ -28,10 +35,10 @@ function mockRunner(overrides: { query.mockRejectedValue(overrides.queryError); } - return { release, connect, query, getTables, getTable }; + return { release, connect, query, getTables, getTable } satisfies MockRunner; } -function createDataSource(runner: ReturnType, dbType: string = 'better-sqlite3') { +function createDataSource(runner: MockRunner, dbType: string = 'better-sqlite3') { return { options: { type: dbType }, createQueryRunner: jest.fn().mockReturnValue(runner), @@ -46,12 +53,13 @@ interface MigrationsPrivate { normalizeClassDates(): Promise; ensureCourseAttendanceSchema(): Promise; protectAttendanceHistory(): Promise; + removeUnusedClassroomColumns(): Promise; } describe('DatabaseMigrationsService — ensureAiConfigTable', () => { let service: MigrationsPrivate & DatabaseMigrationsService; - async function bootstrap(runner: ReturnType) { + async function bootstrap(runner: MockRunner) { const dataSource = createDataSource(runner); const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -220,7 +228,7 @@ describe('DatabaseMigrationsService — course attendance schema', () => { describe('DatabaseMigrationsService — protectAttendanceHistory', () => { let service: MigrationsPrivate & DatabaseMigrationsService; - async function bootstrap(runner: ReturnType, dbType: string = 'better-sqlite3') { + async function bootstrap(runner: MockRunner, dbType: string = 'better-sqlite3') { const dataSource = createDataSource(runner, dbType); const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -416,7 +424,49 @@ describe('DatabaseMigrationsService — protectAttendanceHistory', () => { }); }); -async function bootstrapCourseAttendance(runner: ReturnType) { +describe('DatabaseMigrationsService — classroom cleanup', () => { + let cleanupService: MigrationsPrivate & DatabaseMigrationsService; + + async function bootstrapClassroomCleanup(runner: MockRunner) { + const dataSource = createDataSource(runner); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DatabaseMigrationsService, + { provide: getDataSourceToken(), useValue: dataSource }, + ], + }).compile(); + cleanupService = module.get(DatabaseMigrationsService); + } + + it('drops legacy classroom fields when present', async () => { + const runner = mockRunner({ + getTables: [{ name: 'classrooms', columns: [] }], + getTable: { + name: 'classrooms', + columns: [{ name: 'id' }, { name: 'course_type' }, { name: 'supervisor' }], + }, + }); + await bootstrapClassroomCleanup(runner); + + await cleanupService.removeUnusedClassroomColumns(); + + expect(runner.query).toHaveBeenCalledWith('ALTER TABLE classrooms DROP COLUMN course_type'); + expect(runner.query).toHaveBeenCalledWith('ALTER TABLE classrooms DROP COLUMN supervisor'); + expect(runner.release).toHaveBeenCalled(); + }); + + it('does nothing when the classrooms table is absent', async () => { + const runner = mockRunner({ getTables: [] }); + await bootstrapClassroomCleanup(runner); + + await cleanupService.removeUnusedClassroomColumns(); + + expect(runner.query).not.toHaveBeenCalled(); + expect(runner.release).toHaveBeenCalled(); + }); +}); + +async function bootstrapCourseAttendance(runner: MockRunner) { const dataSource = createDataSource(runner); const module: TestingModule = await Test.createTestingModule({ providers: [ diff --git a/apps/server/src/entities/classroom.entity.ts b/apps/server/src/entities/classroom.entity.ts index c57f2c2..2bb29bf 100644 --- a/apps/server/src/entities/classroom.entity.ts +++ b/apps/server/src/entities/classroom.entity.ts @@ -20,11 +20,6 @@ export class Classroom { @Column({ name: 'room_type', type: 'varchar', length: 20, default: '大' }) roomType: string; // 大 / 次大 / 小 - @Column({ name: 'course_type', length: 50, nullable: true }) - courseType: string; - - @Column({ length: 50, nullable: true }) - supervisor: string; @Column({ type: 'varchar', length: 20, default: 'reserved' }) status: string; diff --git a/docs/superpowers/plans/2026-07-13-classroom-field-simplification.md b/docs/superpowers/plans/2026-07-13-classroom-field-simplification.md new file mode 100644 index 0000000..de54e5c --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-classroom-field-simplification.md @@ -0,0 +1,225 @@ +# Classroom Field Simplification Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove the unused classroom `courseType` and `supervisor` fields from the admin UI, API contract, persistence model, and Excel import flow. + +**Architecture:** Perform a clean cutover across every classroom caller. Add an idempotent bootstrap migration that drops the legacy columns on SQLite and MySQL, while leaving scheduling unchanged because schedule `subject` and class `classType` already own those concepts. + +**Tech Stack:** React 19, Ant Design 6, NestJS 11, TypeORM 0.3, Jest, Vitest, SQLite, MySQL 8 + +## Global Constraints + +- Do not add a replacement abstraction or compatibility alias. +- Preserve classroom name, building, floor, capacity, room type, status, and notes behavior. +- Do not change schedule form fields or schedule conflict behavior. +- Use test-first changes and run focused tests before package builds. + +--- + +### Task 1: Drop Legacy Classroom Columns + +**Files:** +- Modify: `apps/server/src/database/database-migrations.spec.ts` +- Modify: `apps/server/src/database/database-migrations.service.ts:11` +- Modify: `apps/server/src/entities/classroom.entity.ts:23` + +**Interfaces:** +- Consumes: TypeORM `DataSource`, `QueryRunner`, and the existing bootstrap migration sequence. +- Produces: private `removeUnusedClassroomColumns(): Promise` called during application bootstrap. + +- [ ] **Step 1: Write failing migration tests** + +Extend `MigrationsPrivate` with `removeUnusedClassroomColumns(): Promise`. Add focused tests proving that the method: + +```typescript +it('drops legacy classroom fields when present', async () => { + const runner = mockRunner({ + getTable: { + name: 'classrooms', + columns: [{ name: 'id' }, { name: 'course_type' }, { name: 'supervisor' }], + }, + }); + await bootstrapClassroomCleanup(runner); + + await service.removeUnusedClassroomColumns(); + + expect(runner.query).toHaveBeenCalledWith('ALTER TABLE classrooms DROP COLUMN course_type'); + expect(runner.query).toHaveBeenCalledWith('ALTER TABLE classrooms DROP COLUMN supervisor'); + expect(runner.release).toHaveBeenCalled(); +}); + +it('does nothing when classrooms or legacy fields are absent', async () => { + const runner = mockRunner({ getTables: [], getTable: { name: 'classrooms', columns: [] } }); + await bootstrapClassroomCleanup(runner); + + await service.removeUnusedClassroomColumns(); + + expect(runner.query).not.toHaveBeenCalled(); + expect(runner.release).toHaveBeenCalled(); +}); +``` + +Use a local `bootstrapClassroomCleanup` helper matching the existing Nest testing-module setup. + +- [ ] **Step 2: Run tests and verify RED** + +Run: `npm test -w apps/server -- database/database-migrations.spec.ts --runInBand` + +Expected: FAIL because `removeUnusedClassroomColumns` does not exist. + +- [ ] **Step 3: Implement the idempotent migration** + +Call `await this.removeUnusedClassroomColumns();` from `onApplicationBootstrap()`. Implement the method with one query runner: inspect `classrooms`, return when absent, and issue one `ALTER TABLE classrooms DROP COLUMN ` per present legacy column. Always release the runner in `finally`. + +Remove these entity properties and decorators: + +```typescript +courseType: string; +supervisor: string; +``` + +- [ ] **Step 4: Run migration tests and verify GREEN** + +Run: `npm test -w apps/server -- database/database-migrations.spec.ts --runInBand` + +Expected: PASS. + +### Task 2: Remove Fields From API and Excel Import + +**Files:** +- Modify: `apps/server/src/classrooms/dto/classroom.dto.ts:3` +- Modify: `apps/server/src/classrooms/classrooms.controller.ts:48` +- Modify: `apps/server/src/classrooms/classrooms.service.ts:126` +- Create: `apps/server/src/classrooms/classrooms.controller.spec.ts` + +**Interfaces:** +- Consumes: `CreateClassroomDto`, `UpdateClassroomDto`, classroom Excel template/import endpoints. +- Produces: classroom requests and spreadsheets containing only `name`, `building`, `floor`, `roomType`, `capacity`, and optional `notes` where applicable. + +- [ ] **Step 1: Write a failing Excel contract test** + +Instantiate `ClassroomsController` with mocked service/log dependencies, invoke `downloadTemplate`, capture the workbook response buffer, load it with ExcelJS, and assert: + +```typescript +expect(worksheet.getRow(1).values).toEqual([ + undefined, + '教室名', + '楼栋', + '楼层', + '类型', + '容量', +]); +``` + +Also assert the usage notes no longer mention course type or supervisor. + +- [ ] **Step 2: Run test and verify RED** + +Run: `npm test -w apps/server -- classrooms/classrooms.controller.spec.ts --runInBand` + +Expected: FAIL because the generated template still includes `课程类型` and `负责人`. + +- [ ] **Step 3: Remove fields from server contracts** + +Delete `courseType` and `supervisor` from both DTO classes. Remove them from: + +- template columns, example rows, and usage notes; +- parsed import rows; +- `batchImport` row type. + +Keep the existing field order `name`, `building`, `floor`, `roomType`, `capacity` consistent between export and import. + +- [ ] **Step 4: Run focused server tests** + +Run: `npm test -w apps/server -- classrooms/classrooms.controller.spec.ts database/database-migrations.spec.ts --runInBand` + +Expected: PASS. + +### Task 3: Simplify Classroom Admin UI + +**Files:** +- Modify: `apps/admin/src/pages/Classrooms/index.tsx:143` +- Create: `apps/admin/src/pages/Classrooms/classroom-fields.ts` +- Create: `apps/admin/src/pages/Classrooms/classroom-fields.integration.test.ts` + +**Interfaces:** +- Consumes: classroom records returned by `/classrooms`. +- Produces: exported `CLASSROOM_VISIBLE_FIELDS` used to document/test the UI contract; list and form without `courseType` or `supervisor`. + +- [ ] **Step 1: Write a failing UI contract test** + +Create a small contract test: + +```typescript +import { describe, expect, it } from 'vitest'; +import { CLASSROOM_VISIBLE_FIELDS } from './classroom-fields'; + +describe('classroom visible fields', () => { + it('excludes obsolete course and supervisor metadata', () => { + expect(CLASSROOM_VISIBLE_FIELDS).toEqual([ + 'name', + 'building', + 'floor', + 'roomType', + 'capacity', + 'status', + 'notes', + ]); + }); +}); +``` + +- [ ] **Step 2: Run test and verify RED** + +Run: `npm test -w apps/admin -- src/pages/Classrooms/classroom-fields.integration.test.ts` + +Expected: FAIL because `classroom-fields.ts` does not exist. + +- [ ] **Step 3: Implement minimal UI cleanup** + +Create the constant exactly as asserted. Remove the `课程类型` and `负责人` table columns and the corresponding two `Form.Item` blocks. Do not alter scheduling pages. + +- [ ] **Step 4: Run focused admin test** + +Run: `npm test -w apps/admin -- src/pages/Classrooms/classroom-fields.integration.test.ts` + +Expected: PASS. + +### Task 4: Verify Clean Cutover + +**Files:** +- Verify: `apps/server/src/entities/classroom.entity.ts` +- Verify: `apps/server/src/classrooms/` +- Verify: `apps/admin/src/pages/Classrooms/` + +**Interfaces:** +- Consumes: completed Tasks 1–3. +- Produces: buildable admin/server packages with no classroom `courseType` or `supervisor` path. + +- [ ] **Step 1: Run focused tests together** + +Run: `npm test -w apps/server -- classrooms/classrooms.controller.spec.ts database/database-migrations.spec.ts --runInBand` + +Run: `npm test -w apps/admin -- src/pages/Classrooms/classroom-fields.integration.test.ts` + +Expected: all PASS. + +- [ ] **Step 2: Run package typechecks/builds** + +Run: `npm run typecheck -w apps/server` + +Run: `npm run build -w apps/admin` + +Expected: both exit successfully. + +- [ ] **Step 3: Smoke-test observable contracts** + +Start the existing app and verify: + +1. Adding/editing a classroom shows no course type or supervisor field. +2. Classroom list shows neither obsolete column. +3. Downloaded import template has five headers: 教室名、楼栋、楼层、类型、容量. +4. Creating a schedule still selects and saves a classroom normally. + +Expected: all four scenarios succeed without console or API errors. diff --git a/docs/superpowers/specs/2026-07-13-classroom-field-simplification-design.md b/docs/superpowers/specs/2026-07-13-classroom-field-simplification-design.md new file mode 100644 index 0000000..ee1240d --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-classroom-field-simplification-design.md @@ -0,0 +1,31 @@ +# 教室字段精简设计 + +## 目标 + +删除教室中没有业务联动的“课程类型”和“负责人/班主任”,避免与班级班型、真实教师关系重复维护。 + +## 范围 + +- 教室创建和编辑表单删除 `courseType`、`supervisor`。 +- 教室列表删除“课程类型”“负责人”两列。 +- 教室创建、更新 DTO 删除对应字段。 +- 教室实体删除对应列,并通过数据库迁移删除已有列。 +- Excel 导入、导入模板和示例删除对应字段。 +- 排课保持现状:`subject` 表示科目,班型来自所选班级的 `classType`,教室不重复记录班型或课程类型。 + +## 数据流 + +教室只维护名称、楼栋、楼层、容量、规格、状态和备注。创建排课时选择班级、科目、教师、教室及时间;系统继续按教室和时间检测排课及租赁冲突,不增加课程类型匹配规则。 + +## 兼容策略 + +采用干净切换:所有调用方同时迁移,不保留 DTO 字段、实体列、别名或兼容逻辑。迁移删除历史 `course_type`、`supervisor` 数据。 + +## 验证 + +- 管理端构建通过。 +- 服务端相关测试和构建通过。 +- 创建、编辑教室时不再显示或提交两个字段。 +- 教室列表不再显示两列。 +- Excel 模板及导入不再包含两个字段。 +- 创建排课仍可正常选择教室并保存。