Client IP is derived by blindly trusting the `x-forwarded-for`/`x-real-ip` headers. These headers are client-controlled unless the server is only reachable behind a trusted reverse proxy that overwrites them. If the service can be hit directly, an attacker can spoof any IP with `X-Forwarded-For: <victim-ip>`, bypassing IP-based rate limiting / access control and poisoning audit logs. Suggest honoring these headers only when the direct peer (`req.connection.remoteAddress`) is a known trusted proxy (or relying on the framework's `trust proxy` setting), and falling back to the connection address otherwise.

  const forwarded =
    req.headers?.['x-forwarded-for'] ||
    req.headers?.['x-real-ip'] ||
    req.connection?.remoteAddress ||
    '';


`split(',')[0]` takes the leftmost value of `X-Forwarded-For`. In a standard proxy chain each proxy appends the client address to the right (e.g. `XFF: <client-supplied>, <real-client>`), so the leftmost value is the original client-supplied (spoofable) one, while the real client IP is the rightmost / last untrusted hop. This compounds the spoofing issue above: even behind a trusted proxy that only appends, the extracted IP will be wrong/spoofable. Consider parsing from the right based on the number of trusted proxies, or having the proxy overwrite the header / use `x-real-ip` as the authoritative source.

const ipAddress = String(forwarded).split(',')[0].trim() || 'unknown';


Timezone bug: `new Date('YYYY-MM-DD')` parses as UTC midnight, but `d.getDate()` / `d.setDate()` below run in the server's local timezone. On any server west of UTC (or in a DST-observing timezone) matrix entries get placed on the wrong calendar day (e.g., UTC-5: Aug 9 00:00 UTC = Aug 8 19:00 local → `getDate()` returns 8), and the day-by-day iteration can skip/duplicate days. The rest of this file deliberately uses UTC-based helpers (`toUtcDate` + `dayjs(...).utcOffset(8)`), so this path is inconsistent. Parse/iterate with UTC helpers instead, e.g. build `effStart/effEnd` via `toUtcDate` and use `getUTCDate()`/`setUTCDate()` (or `dayjs(...).utcOffset(8)`).

      const start = new Date(rental.startDate);
      const end = new Date(rental.endDate);


Same timezone issue as the rental loop: `d.getDay()` here is the local weekday, so on servers west of UTC the weekday check `dow !== sched.weekDay` and the day key `d.getDate()` can be off by one day, producing wrong schedule overlay days. Use UTC-based day/weekday helpers consistently.

const dow = d.getDay() === 0 ? 7 : d.getDay();


Status mapping is wrong for ended rentals: any status other than CANCELLED (including ENDED) is written to `class_schedules` as `'active'`. A rental that has ended will leave an active RENTAL schedule in the DB, which can block/reuse the period incorrectly in other features reading `class_schedules.status = 'active'` (e.g. attendance or teacher-schedule views). Map ENDED → `'ended'` (or remove/ignore the schedule) instead of defaulting everything to `'active'`, and prefer enum constants over string literals.

status: rental.status === ClassroomRentalStatus.CANCELLED ? 'cancelled' : 'active',


Hardcoded string literals `'active'` and `'INTERNAL'` are repeated across this file (`findConflicts`, `getSchedule`, `syncScheduleFromRental`). `ClassroomRentalStatus.ACTIVE` and the exported `ScheduleType.INTERNAL` enum already exist and should be reused so the values cannot drift (a typo here silently disables conflict detection).

      .andWhere('cs.status = :status', { status: 'active' })
      .andWhere('cs.scheduleType = :scheduleType', { scheduleType: 'INTERNAL' })


`any` is used for the map/matrix value types. Since the cell shape is known (scheduleType/rentalId/organizationName/color, or scheduleId/subject/teacherName/startTime/endTime), define a small discriminated interface (e.g. `RentalCell | InternalCell`) so the frontend contract is type-checked instead of unchecked `any`.

    const organizationMap = new Map<number, any>();
    const matrix: Record<number, Record<number, any>> = {};


`dayjs('2026-8-01')` / `dayjs('2026-8')` are non-ISO strings: dayjs falls back to engine-dependent `new Date()` parsing for them (works in V8, fails or mis-parses in other engines), and the resulting `monthStart` string is only lexicographically comparable with DB dates because of the `.format()` zero-padding. Zero-pad the month explicitly to be safe: `dayjs(`${year}-${String(month).padStart(2, '0')}-01`)` and same for `monthEnd`.

    const monthStart = dayjs(`${year}-${month}-01`).format('YYYY-MM-DD');
    const monthEnd = dayjs(`${year}-${month}`).endOf('month').format('YYYY-MM-DD');


`dateToWeekDay` uses `new Date(date)` + local `getDay()`, which is inconsistent with the UTC-based `toUtcDate` helper used everywhere else in this file and will return the previous day's weekday on servers with a negative UTC offset (shifting the synced schedule to the wrong weekday). Use `this.toUtcDate(date).getUTCDay()` (mapping 0→7).

  dateToWeekDay(date: string): number {
    const d = new Date(date);
    const day = d.getDay();


Non-atomic check-then-create: `findOne` followed by `create`/`save` has a race window — two concurrent calls for the same rental can both find no schedule and insert duplicate RENTAL rows. Add a unique index on `(rental_id, schedule_type)` (where rental_id is not null) and/or catch a unique-violation error and update instead of insert.

    let schedule = await this.scheduleRepo.findOne({
      where: { rentalId: rental.id, scheduleType: 'RENTAL' },
    });


Authorization/data-scoping inconsistency: unlike `getStats`/`getClassAttendanceRanking`, these endpoints never resolve the requesting user's accessible class IDs. `getGanttData`, `getExpenseStats` and `getRoomExpenseRanking` return global (all rooms/expenses) data to any authenticated user holding the `dashboard:view` permission, even non-admin teachers — a horizontal privilege escalation / sensitive data exposure (esp. expense figures). Either pass `req` through `getAccessibleClassIds(req)` (and have the service scope the queries) or restrict these routes to admins/super-admins explicitly.

  @Get('gantt')
  getGanttData(@Query() query: DashboardGanttQueryDto) {
    return this.service.getGanttData(query);
  }


Same authorization gap as the `gantt`/`expense-stats`/`room-ranking` endpoints: `getClassroomOccupancy` and `getClassroomUtilization` ignore the requesting user and expose global classroom schedule/rental data to any user with `dashboard:view`, including non-admin teachers. These should be scoped by the caller's accessible class IDs or explicitly restricted to admin-only roles.

  @Get('classroom-occupancy')
  getClassroomOccupancy() {
    return this.service.getClassroomOccupancy();
  }


If `logService.log()` throws (e.g. DB failure) after `operation()` has already succeeded, the exception propagates and the caller sees a failure even though the business operation was executed — it may retry the operation and produce duplicate side effects. Audit logging should not be able to fail the business operation; wrap the log call in try/catch and log the audit error instead of re-throwing.

const result = await operation();
  try {
    await logService.log({
      userId: req.user?.id,
      username: req.user?.username,
      ipAddress,
      userAgent,
      ...buildEntry(result),
    });
  } catch (err) {
    // 审计写入失败不应影响业务结果，仅记录错误
    console.error('Failed to write audit log', err);
  }
  return result;

const result = await operation();
  await logService.log({


When `operation()` throws, no audit record (including a failure entry) is written at all. For an audit-trail utility, failed attempts are typically the most important events to record. Consider catching the operation error, writing an entry with `status: 'failed'` (the `status` field already exists in `AuditLogEntry`), and then re-throwing the original error.

operation: () => Promise<T>,
): Promise<T> {
  const { ipAddress, userAgent } = extractRequestInfo(req);
  try {
    const result = await operation();
    await logService.log({ userId: req.user?.id, username: req.user?.username, ipAddress, userAgent, ...buildEntry(result) });
    return result;
  } catch (err) {
    await logService
      .log({ userId: req.user?.id, username: req.user?.username, ipAddress, userAgent, ...buildEntry(err as T), status: 'failed' })
      .catch(() => {});
    throw err;
  }

operation: () => Promise<T>,
): Promise<T> {
  const { ipAddress, userAgent } = extractRequestInfo(req);
  const result = await operation();


`withAuditLog` and `logAudit` duplicate the same extract-and-log logic (both call `extractRequestInfo` and build the same `logService.log` payload). Extract a small private helper (e.g. `writeAuditLog(logService, req, entry)`) and reuse it in both functions to avoid divergence.

export async function logAudit(
  logService: OperationLogsService,
  req: AuditRequest,
  entry: AuditLogEntry,
): Promise<void> {
  const { ipAddress, userAgent } = extractRequestInfo(req);
  await logService.log({


Usage report overcounts schedule days: `ClassSchedule` is a weekly recurring rule (single `weekDay` 1–7 spanning `startDate`–`endDate`, as used in `getUsageForClassrooms`), but this loop counts *every* calendar day in the range. A Monday-only weekly class spanning a month is counted as ~29 days instead of ~4-5, inflating `scheduleDays`/`usedDays` and `occupancyRate`. Only count days whose `getDay()` matches `s.weekDay` (JS: 0=Sun..6=Sat, so `target = s.weekDay % 7`).

    for (const s of schedules) {
      if (!scheduleDaysByRoom[s.classroomId]) scheduleDaysByRoom[s.classroomId] = new Set();
      const effStart = new Date(Math.max(new Date(s.startDate).getTime(), start.getTime()));
      const effEnd = new Date(Math.min(new Date(s.endDate).getTime(), end.getTime()));
      const targetJsDay = s.weekDay % 7; // weekDay: 1=Mon..7=Sun -> JS getDay(): 0=Sun..6=Sat
      for (let d = new Date(effStart); d <= effEnd; d.setDate(d.getDate() + 1)) {
        if (d.getDay() !== targetJsDay) continue;
        scheduleDaysByRoom[s.classroomId].add(dayjs(d).utcOffset(8).format('YYYY-MM-DD'));
      }
    }

    for (const s of schedules) {
      if (!scheduleDaysByRoom[s.classroomId]) scheduleDaysByRoom[s.classroomId] = new Set();
      const effStart = new Date(Math.max(new Date(s.startDate).getTime(), start.getTime()));
      const effEnd = new Date(Math.min(new Date(s.endDate).getTime(), end.getTime()));
      for (let d = new Date(effStart); d <= effEnd; d.setDate(d.getDate() + 1)) {
        scheduleDaysByRoom[s.classroomId].add(dayjs(d).utcOffset(8).format('YYYY-MM-DD'));
      }
    }


`usedDays` is the plain sum of `rentalDays + scheduleDays`, so a single day with both a rental and a schedule is double-counted. This can make `usedDays > totalDays`, producing negative `idleDays` and occupancy rates above 100%. Compute the union of the two day sets instead.

      const rentalDays = rentalDaysByRoom[c.id]?.size || 0;
      const scheduleDays = scheduleDaysByRoom[c.id]?.size || 0;
      const usedDays = new Set([...rentalDaysByRoom[c.id] ?? [], ...scheduleDaysByRoom[c.id] ?? []]).size;

      const rentalDays = rentalDaysByRoom[c.id]?.size || 0;
      const scheduleDays = scheduleDaysByRoom[c.id]?.size || 0;
      const usedDays = rentalDays + scheduleDays;


`update()` has no duplicate-name guard, unlike `create()` which rejects existing names. If `dto.name` is changed to a name already used by another classroom, duplicate classroom names will be created (there is no unique DB constraint on `classrooms.name`). Add the same existence check before updating when `dto.name` differs from the current name.

    if (dto.status === ClassroomStatus.MAINTENANCE && classroom.status !== dto.status) {
      await this.assertNoActiveAllocations(id);
    }
    if (dto.name && dto.name !== classroom.name) {
      const exists = await this.repo.findOne({ where: { name: dto.name } });
      if (exists) throw new BadRequestException(`教室 ${dto.name} 已存在`);
    }
    await this.repo.update(id, dto);

    if (dto.status === ClassroomStatus.MAINTENANCE && classroom.status !== dto.status) {
      await this.assertNoActiveAllocations(id);
    }
    await this.repo.update(id, dto);


`batchImport` runs sequential `findOne` + `save` per row (N+1 DB round-trips), and any DB error (e.g. a constraint violation on one row) throws out of the loop, aborting the rest of the import with a partial insert and no rollback or error reporting. Wrap each row in try/catch to collect per-row errors, and consider batching with `Promise.all` (with per-row error isolation) for large imports.

    for (const row of rows) {
      if (!row.name?.trim()) {
        skipped++;
        continue;
      }
      try {
        const exists = await this.repo.findOne({ where: { name: row.name.trim() } });
        if (exists) {
          errors.push(`教室 ${row.name} 已存在`);
          skipped++;
          continue;
        }
        await this.repo.save(this.repo.create({ ...row, capacity: row.capacity || 30 }));
        imported++;
      } catch (err) {
        errors.push(`教室 ${row.name} 导入失败`);
        skipped++;
      }
    }

    for (const row of rows) {
      if (!row.name?.trim()) {
        skipped++;
        continue;
      }
      const exists = await this.repo.findOne({ where: { name: row.name.trim() } });
      if (exists) {
        errors.push(`教室 ${row.name} 已存在`);
        skipped++;
        continue;
      }
      await this.repo.save(this.repo.create({ ...row, capacity: row.capacity || 30 }));
      imported++;
    }


`String(schedule.startDate)` is only safe when the driver returns a date string ('YYYY-MM-DD'). If the driver returns a `Date` object (the raw-row interface at the top of the file explicitly allows `string | Date`), `String(Date)` yields 'YYYY-MM-DDT00:00:00.000Z', which lexicographically compares *greater* than `todayStr` — so a schedule/rental starting today would never be flagged as in use. Normalize to a 10-char prefix (or `dayjs().format('YYYY-MM-DD')`) before comparing. Same issue applies to the rental `isCurrent` check.

      const startDateStr = String(schedule.startDate).slice(0, 10);
      const endDateStr = String(schedule.endDate).slice(0, 10);
      const isCurrent =
        startDateStr <= todayStr &&
        endDateStr >= todayStr &&
        Number(schedule.weekDay) === weekDay &&
        String(schedule.startTime) <= currentTime &&
        String(schedule.endTime) >= currentTime;

      const isCurrent =
        String(schedule.startDate) <= todayStr &&
        String(schedule.endDate) >= todayStr &&
        Number(schedule.weekDay) === weekDay &&
        String(schedule.startTime) <= currentTime &&
        String(schedule.endTime) >= currentTime;


Business status/type values are hardcoded as string literals ('active', 'INTERNAL', 'archived', 'available', 'maintenance') scattered across queries and status assignments. The entities already export enums (`ScheduleType`, `ClassroomStatus`, `ClassroomRentalStatus`); using them (e.g. `ScheduleType.INTERNAL`, `ClassroomStatus.AVAILABLE`) prevents silent breakage if the stored values ever change and improves type safety.

.andWhere('s.scheduleType = :type', { type: ScheduleType.INTERNAL })

.andWhere('s.scheduleType = :type', { type: 'INTERNAL' })


This loop executes 6 sequential DB queries (one per month), multiplying latency by 6 round trips. Since the queries are independent, run them in parallel with Promise.all, or better, collapse them into a single query that groups `periodStart` by month. Sequential awaits in loops over independent async work should be avoided.

  const months = Array.from({ length: 6 }, (_, i) =>
    dayjs.utc(`${currentMonth}-01`).subtract(5 - i, 'month').format('YYYY-MM'),
  );
  return Promise.all(months.map(async (m) => {
    const row = await billRepo
      .createQueryBuilder('b')
      .select('SUM(b.totalAmount)', 'total')
      .where('b.status = :paid', { paid: 'paid' })
      .andWhere('b.periodStart >= :start', { start: `${m}-01` })
      .andWhere('b.periodStart < :end', { end: nextMonth(m) })
      .getRawOne<{ total: string | number | null }>();
    return { month: m, amount: parseFloat(String(row?.total || '0')) };
  }));

  for (let i = 5; i >= 0; i--) {
    const m = dayjs.utc(`${currentMonth}-01`).subtract(i, 'month').format('YYYY-MM');

    const row = await billRepo


All four constructor-injected repositories (`attendanceRepo`, `billRepo`, `occRepo`, `expRepo`) are never referenced inside this class — every method takes its repository as an explicit parameter instead (confirmed by callers in dashboard.service.ts passing repos in). This makes the @InjectRepository constructor parameters dead code and hides the actual data-source wiring. Either use `this.attendanceRepo`/`this.billRepo`/etc. inside the methods and drop the parameters, or remove the constructor injection so the explicit parameter-passing pattern is the documented contract.

export class DashboardQueriesService {
  constructor(
    @InjectRepository(AttendanceRecord) private readonly attendanceRepo: Repository<AttendanceRecord>,
    @InjectRepository(Bill) private readonly billRepo: Repository<Bill>,
    @InjectRepository(Occupancy) private readonly occRepo: Repository<Occupancy>,
    @InjectRepository(RoomExpense) private readonly expRepo: Repository<RoomExpense>,
  ) {}


`rate` has an inconsistent type: it is a string (`toFixed(1)`) when `total > 0` but a number (`0`) otherwise. `getClassAttendanceRanking` in the same file returns the rate as a parsed number, so consumers of this API get different shapes from sibling endpoints. Return a number consistently (e.g. `parseFloat(...toFixed(1))`), matching the other method.

rate: d.total > 0 ? parseFloat(((d.present / d.total) * 100).toFixed(1)) : 0,

rate: d.total > 0 ? ((d.present / d.total) * 100).toFixed(1) : 0,


`room.roomNumber` is selected but only `e.roomId` is in the GROUP BY. Under `ONLY_FULL_GROUP_BY` (MySQL default in recent versions) or PostgreSQL this query fails or returns a non-deterministic roomNumber. Add `room.roomNumber` to the GROUP BY (or group by `room.id`) to be safe and portable.

    .groupBy('e.roomId')
    .addGroupBy('room.roomNumber')

    .select('room.roomNumber', 'roomNumber')
    .addSelect('SUM(e.amount)', 'total')
    .where('room.status != :archived', { archived: 'archived' })
    .groupBy('e.roomId')


Contradictory validation rules make this property impossible to validate successfully. `@Matches(/^\d{4}-\d{2}-\d{2}$/)` only accepts a bare `YYYY-MM-DD` date, but `@IsISO8601({ strict: true })` (validator.js strict mode) requires a full ISO 8601 timestamp with the `T` separator, time and timezone (e.g. `2024-01-01T00:00:00.000Z`). Because class-validator requires all constraints on a property to pass, no value can ever satisfy both — every request using this DTO will be rejected with a 400. Pick one format: e.g. drop `strict: true` so `@IsISO8601()` accepts plain dates, or align the regex to the strict ISO format.

  @Matches(/^\d{4}-\d{2}-\d{2}$/)
  @IsISO8601()
  periodStart?: string;

  @Matches(/^\d{4}-\d{2}-\d{2}$/)
  @IsISO8601({ strict: true })
  periodStart?: string;


Same contradiction as `periodStart`: `@Matches(/^\d{4}-\d{2}-\d{2}$/)` allows only a date-only string while `@IsISO8601({ strict: true })` requires a full timestamp with time and timezone, so the two constraints can never pass together. Align the formats (e.g. remove `strict: true` if only `YYYY-MM-DD` is intended).

  @Matches(/^\d{4}-\d{2}-\d{2}$/)
  @IsISO8601({ strict: true })
  periodEnd?: string;


The `schedQb`/`rentalQb` COUNT(DISTINCT) queries are redundant with the `combinedQb`/`combinedRentalQb` id-list queries below — they run with identical WHERE conditions. `scheduleCount`/`rentalCount` can be derived from `schedIds.length`/`rentalIds.length`, saving two DB round-trips. Consider merging to a single pair of queries.

const schedResult = await schedQb.getRawOne<{ cnt: string | number | null }>();


This only checks `endDate >= today`, so rentals whose `startDate` is still in the future are counted as "active" as long as their status is 'active'. Every other rental/schedule query in this file checks both `startDate <= today AND endDate >= today`. Add a `startDate: LessThanOrEqual(todayStr)` condition for consistency.

    const activeRentals = await this.rentalRepo.count({
      where: { status: 'active' as const, endDate: MoreThanOrEqual(todayStr) },
    });


Denominator/numerator mismatch: `classroomCount` counts ALL classrooms (`where: {}`, no status filter) while `occupiedClassrooms` counts any classroom with an active schedule today — no `status: 'available'` filter and no rentals included. This yields a different metric than `getClassroomUtilizationStats`/`getClassroomOccupancy` (which use `status: 'available'` and merge schedules + rentals). Align the definitions so the reported occupancy rate is consistent across endpoints.

const classroomCount = await this.classroomRepo.count({ where: {} });


Return type is inconsistent: this expression yields a `string` (via `toFixed(1)`) when `classroomCount > 0` but a number `0` otherwise, and unlike `occupancyRate` (fallback `'0.0'`) it is not a string. Normalize to a single type (e.g., always return a string like `'0.0'`) to avoid brittle frontend handling.

    const classroomOccupancyRate =
      classroomCount > 0 ? ((occupiedClassrooms / classroomCount) * 100).toFixed(1) : 0;


This loads the full `ClassStudent` rows into memory just to count distinct `studentId`s. For large class sizes this is wasteful — restrict the projection (e.g., `select: ['studentId']`) or, better, push the distinct count down to the DB (e.g., a query builder with `COUNT(DISTINCT studentId)`).

    const classStudents = await this.classStudentRepo.find({
      where: { classId: In(accessibleClassIds), status: 'active' },
    });


Inconsistency with `agentGetDashboardStats`: here the unscoped branch counts all classes including archived ones (`where: {}`), while the agent variant filters `isArchived: false`; also `accessibleClassIds.length` may include archived classes. Use a consistent archive filter for `classCount` in both methods.

    const classCount = accessibleClassIds
      ? accessibleClassIds.length
      : await this.classRepo.count({ where: {} });


The `* 3` weight applied to `rentalCount` is a magic business number with no explanation, making the resulting occupancy metric hard to interpret or adjust. Extract it into a named constant (e.g., `RENTAL_WEIGHT = 3`) and add a comment describing the rationale.

occupancy: Math.min(((sMap[c.id] || 0) + (rMap[c.id] || 0) * 3) / 7, 1),


Suspicious naming/logic: the metric is called `pendingDeposits` but it sums deposits with `status = 'paid'` — which describes collected (non-pending) deposits. If the intent is unrefunded/held deposits, the status filter is likely wrong (should probably target an 'unrefunded'/'pending' status); otherwise rename the field to reflect that it sums paid deposits. Please verify against the deposit status enum.

    const pendingQb = this.depositRepo
      .createQueryBuilder('d')
      .select('SUM(d.amount)', 'total')
      .where('d.status = :paid', { paid: 'paid' });


If `fn(runner)` rejects and `runner.release()` also throws (e.g., a driver-level failure while returning the connection to the pool), the exception thrown by `release()` in the `finally` block will replace the original error from `fn`, losing the real failure cause and making debugging harder. Consider guarding `release()` with its own try/catch (logging the release error) so the original exception propagates. Also note that if `runner.connect()` itself fails, the runner is never released — usually harmless since no connection was established, but worth handling explicitly if the pool driver requires cleanup.

  } finally {
    try {
      await runner.release();
    } catch (releaseError) {
      // Log releaseError but do not mask the original error from fn.
    }
  }

  } finally {
    await runner.release();
  }


The CREATE TABLE branch omits the `reasoning_effort` column, although the ALTER branch (`desiredColumns`) adds it for pre-existing tables. A fresh install therefore lacks `reasoning_effort` until this migration is run a second time, resulting in different schemas depending on whether the table already existed. Add `reasoning_effort` to the CREATE TABLE statement (or run the same column-sync loop after creating the table).

            enabled ${boolType} DEFAULT 0,
            timeout_ms INT DEFAULT 30000,
            reasoning_effort VARCHAR(20),
            verified ${boolType} DEFAULT 0,

            enabled ${boolType} DEFAULT 0,
            timeout_ms INT DEFAULT 30000,
            verified ${boolType} DEFAULT 0,


In MySQL, `DEFAULT CURRENT_TIMESTAMP` only applies at INSERT time. Without `ON UPDATE CURRENT_TIMESTAMP`, `updated_at` will never be refreshed by UPDATE statements, so it does not actually track modifications even though it is declared NOT NULL DEFAULT. If DB-managed timestamps are intended, add `ON UPDATE ${datetimeFn}` to `updated_at`; otherwise the column is misleading.

            created_at DATETIME NOT NULL DEFAULT ${datetimeFn},
            updated_at DATETIME NOT NULL DEFAULT ${datetimeFn} ON UPDATE ${datetimeFn}


These two columns are added as nullable `DATETIME` without NOT NULL/DEFAULT, so existing rows get NULL and future INSERTs must supply explicit values — inconsistent with the CREATE branch which uses `NOT NULL DEFAULT CURRENT_TIMESTAMP`. Use the same definition as the CREATE branch for schema consistency, and consider backfilling existing rows.

        { name: 'created_at', def: 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP' },
        { name: 'updated_at', def: 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP' },

        { name: 'created_at', def: 'DATETIME' },
        { name: 'updated_at', def: 'DATETIME' },


The empty catch swallows every error from unique-index creation, not just the "index already exists" case (MySQL error 1061 / ER_DUP_KEYNAME). If the table already contains duplicate `singleton_key` values, the failure is silently ignored and the uniqueness constraint is never enforced. Check the driver error code before ignoring the failure.

      } catch (err) {
        // MySQL has no IF NOT EXISTS for indexes; ignore only duplicate-key errors (1061)
        const code = (err as { code?: string })?.code;
        if (code !== 'ER_DUP_KEYNAME') {
          throw err;
        }
      }

      } catch {
        // Index may already exist; MySQL has no IF NOT EXISTS for indexes
      }


The table is created using a check-then-create pattern (`!tableNames.has('attendance_sessions')` followed by a plain `CREATE TABLE` without `IF NOT EXISTS`). Under concurrent multi-instance startup (or if the table is created between the `getTables` check and the `CREATE TABLE`), the second instance will fail with "table already exists" and abort startup — unlike `ding_leave_raw` which correctly uses `CREATE TABLE IF NOT EXISTS`. Additionally, this DDL references `class_schedule`/`classes`, which may not exist yet on a fresh database. Suggest using `CREATE TABLE IF NOT EXISTS` for idempotency.

CREATE TABLE IF NOT EXISTS ${tableName} (


The `ALTER TABLE attendance_records` statements execute without verifying the table actually exists. If `attendance_records` has not been created yet (fresh database or changed migration ordering), `runner.getTable('attendance_records')` returns undefined and the ALTER will throw, aborting the entire startup. The `class_schedule` branch above correctly guards with a table-existence check first; this branch should do the same (e.g., check `attendanceTable` before altering).

    if (!columnNames.has('schedule_id')) {
      await runner.query('ALTER TABLE attendance_records ADD COLUMN schedule_id INTEGER');
    }


The drop loop removes ALL foreign keys on `schedule_id`/`class_id` — including the `fk_as_schedule_protect`/`fk_as_class_protect` constraints added at the end of this same function. As a result, on every startup these constraints are dropped and then re-added, so the later "skip if DELETE_RULE = RESTRICT" check is effectively dead code. This causes redundant DDL (metadata/table locks) on a table that grows over time and leaves a window during startup where `attendance_sessions` has no delete protection. Suggest excluding the two known protect constraint names (or the RESTRICT ones) from the drop loop.

    for (const row of fkRows) {
      try {
        await runner.query(
          `ALTER TABLE attendance_sessions DROP FOREIGN KEY \`${row.CONSTRAINT_NAME}\``,
        );


`createIndex` catches and swallows ALL errors, not just "index already exists" (MySQL error 1061 / ER_DUP_KEYNAME). Genuine failures — e.g., duplicate rows preventing a UNIQUE index from being created, or the target table missing — are silently ignored and the app starts without the required index. Suggest narrowing the catch to the duplicate-key-name error (and logging/re-throwing everything else) so real problems are not masked during startup.

    const createIndex = async (sql: string) => {
      try {
        await runner.query(sql);
      } catch {
        // Existing MySQL indexes cannot use IF NOT EXISTS; startup must stay idempotent.
      }
    };


All 15 migrations are chained in `onApplicationBootstrap` without any error handling or logging, and `this.logger` is never used here (only 5 of the 15 functions receive a logger). If any migration fails, the exception aborts the entire NestJS bootstrap with no indication of which migration failed or why, and the remaining migrations never run. Add per-step try/catch (or at least log before/after each step) so failures are identifiable and actionable, e.g.: `this.logger.log('Ensuring sync state lease columns...')` before each step and a try/catch that logs the failed migration name before rethrowing.

  async onApplicationBootstrap(): Promise<void> {
    await this.ensureAiConfigTable();
    await this.ensureSyncStateLeaseColumns();


Each of the 15 public wrapper methods is a one-line pass-through that forwards to an imported function of the same name (e.g., `ensureSyncStateLeaseColumns` -> `ensureSyncStateLeaseColumns(this.dataSource)`). This is duplicate boilerplate that can drift from the imported implementations and adds ~30 lines with no added logic. Consider calling the imported functions directly in `onApplicationBootstrap` (or collapsing the wrappers) unless they exist specifically as a test/DI surface.

  async ensureSyncStateLeaseColumns(): Promise<void> {
    return ensureSyncStateLeaseColumns(this.dataSource);
  }


Organizations are looked up and re-queried by `name`, which is not unique. If two legacy tenants share the same name, the second tenant will reuse the first tenant's organization (and never create its own, despite having a unique code `ORG_<id>`). The re-query after INSERT can also match a pre-existing organization with the same name (including the host org when a tenant is named like `HOST_ORGANIZATION_NAME`), so `students`/`occupancies`/`classroom_rentals` may be linked to the wrong organization. Use the INSERT result's generated id (e.g., MySQL `insertId`) or query by the unique `code` column (`ORG_${legacy.id}`) for both lookups.

          external = (
            (await runner.query('SELECT * FROM organizations WHERE code = ? LIMIT 1', [`ORG_${String(legacy.id)}`])) as OrganizationRow[]
          )[0];

          external = (
            (await runner.query('SELECT * FROM organizations WHERE name = ? LIMIT 1', [name])) as OrganizationRow[]
          )[0];


`.catch(() => undefined)` silently swallows every UPDATE error. If a legacy schema lacks one of the referenced columns (e.g., `tenant_id`), this tenant-scoped update fails quietly and the later fallback `UPDATE students SET organization_id = ? WHERE organization_id IS NULL` will then assign the host organization to all remaining rows, silently producing incorrect data associations. Log a warning with the error (e.g., `logger.warn`) instead of discarding it so migration failures are visible and diagnosable.

          await runner
            .query(
              'UPDATE students SET organization_id = ? WHERE organization_id IS NULL AND tenant_id = ?',
              [external.id, legacy.id],
            )
            .catch(() => undefined);


The final fallback for `classroom_rentals` only backfills `lessor_organization_id`, leaving `lessee_organization_id` NULL for rows not matched in the tenant loop — whereas the tenant loop sets both sides (`lessee = external, lessor = host`). This asymmetry means some rentals end up half-backfilled. If full backfilling is intended, also set the lessee to the host here (or document why a NULL lessee is acceptable).

    if (tableNames.has('classroom_rentals')) {
      await runner.query(
        'UPDATE classroom_rentals SET lessor_organization_id = ? WHERE lessor_organization_id IS NULL',
        [host.id],
      );
    }


These UPDATEs are data backfills that run on every startup (this function is an idempotent startup migration). They can corrupt legitimate data on subsequent boots: (1) a partially paid bill (0 < paid_amount < total_amount, status <> 'paid') gets outstanding_amount forcibly reset to total_amount, ignoring paid_amount; (2) a legitimately zero-outstanding non-paid bill (e.g., fully refunded/voided or zero-value) is clobbered back to total_amount each boot; (3) if paid bills are later refunded, the second UPDATE keeps overwriting paid_amount back to total_amount. Consider computing outstanding from paid_amount (`SET outstanding_amount = total_amount - paid_amount`) and/or gating this backfill to run only when the column was just added (i.e., inside the `if (!columns.has(...))` branch), not on every boot.

      await runner.query("UPDATE bills SET outstanding_amount = total_amount WHERE outstanding_amount = 0 AND status <> 'paid'");
      await runner.query("UPDATE bills SET paid_amount = total_amount, outstanding_amount = 0 WHERE status = 'paid'");


Unlike the `createIndex` helper used for attendance_devices (which guards against failures to keep startup idempotent), this unique index is created without any error handling. If legacy `room_expenses` rows contain duplicate non-NULL `import_key` values, the CREATE UNIQUE INDEX will throw and abort startup. Wrap it in a try/catch with logging (and/or check for duplicates first) to match the pattern used elsewhere and keep startup resilient.

        await runner.query(
          'CREATE UNIQUE INDEX idx_room_expenses_import_key ON room_expenses (import_key)',
        );


All index-creation errors are silently swallowed with no logging. When the unique index fails (e.g., existing rows share duplicate `device_sn` values, including rows backfilled with the DEFAULT '' value after `ADD COLUMN`), the failure is invisible, uniqueness is silently never enforced, and the statement is retried on every startup. At minimum log a warning so schema drift / duplicate data is surfaced.


The "add columns if missing" pattern (fetch table -> build Set of column names -> loop ALTER TABLE for missing columns) is duplicated in `ensureSyncStateLeaseColumns`, `ensureStudentProfileCollegeColumns`, `ensureAttendanceDevicesSchema`, `ensureStudentWalletSchema` (x3), and `cleanupDepositRefundColumns`. Extracting a shared helper such as `ensureColumns(runner, tableName, additions: Array<[string, string]>)` would remove the repeated logic and reduce the risk of divergence (e.g., the unguarded unique-index vs guarded createIndex inconsistency).

      for (const [name, definition] of additions) {
        if (!columns.has(name)) await runner.query(`ALTER TABLE bills ADD COLUMN ${name} ${definition}`);
      }


All columns here are implicitly nullable because TypeORM only sets NOT NULL when `nullable: false` is specified. The service layer treats `periodKey`, `label`, `startTime`, and `endTime` as mandatory (e.g. `saveAttendancePeriodConfigs` throws when the key/label are empty, and period matching depends on `periodKey`), and `sortOrder`/`enabled` always receive values. Since the DB schema does not enforce NOT NULL, a direct insert/update (or a bug in an upstream caller) can silently persist rows with NULL keys/times, breaking period matching and ordering. Add `nullable: false` to these columns (and consider `default: 0`/`default: true` already set for `sortOrder`/`enabled`).

  @Column({ name: 'period_key', type: 'varchar', length: 40, nullable: false })
  periodKey: string;

  @Column({ type: 'varchar', length: 40, nullable: false })
  label: string;

  @Column({ name: 'start_time', type: 'varchar', length: 5, nullable: false })
  startTime: string;

  @Column({ name: 'end_time', type: 'varchar', length: 5, nullable: false })
  endTime: string;

  @Column({ name: 'period_key', type: 'varchar', length: 40 })
  periodKey: string;

  @Column({ type: 'varchar', length: 40 })
  label: string;

  @Column({ name: 'start_time', type: 'varchar', length: 5 })
  startTime: string;

  @Column({ name: 'end_time', type: 'varchar', length: 5 })
  endTime: string;


`start_time`/`end_time` are stored as `varchar(5)`, which fits the "HH:MM" format used by the default configs. However, nothing at the schema level prevents invalid or inconsistently formatted values (e.g. "9:5", "09:5", "09:00:00" would be silently truncated). Consider adding a CHECK constraint (e.g. `start_time REGEXP '^([01][0-9]|2[0-3]):[0-5][0-9]$'`) or validating the format in the DTO with a `@Matches(...)` decorator so data integrity is enforced even when a new write path bypasses the service validation.

  @Column({ name: 'start_time', type: 'varchar', length: 5 })
  startTime: string;

  @Column({ name: 'end_time', type: 'varchar', length: 5 })
  endTime: string;


Nullable DB columns are typed as non-nullable in TypeScript (`category`, `fileName`, `filePath`, `fileSize`, `mimeType` are all `nullable: true`). When the DB row contains NULL, TypeORM returns `null` and downstream code like `attachment.fileName.toUpperCase()` will throw at runtime even though TypeScript sees a `string`. Type these properties as `string | null` (and `number | null` for `fileSize`) to force callers to perform null checks.

  @Column({ length: 50, nullable: true })
  category: string | null;

  @Column({ length: 50, nullable: true })
  category: string;


`eager: true` forces TypeORM to always JOIN and load the full `Student` row on every `ArchiveAttachment` query (including list queries that only need the attachment itself), which can cause unnecessary data fetching and performance degradation in bulk operations. Prefer removing `eager` and explicitly requesting the relation via query `relations` options. Also, no index is defined on `student_id`, so add `@Index()` on the `studentId` column if lookups by student are frequent.

  @ManyToOne(() => Student)
  @JoinColumn({ name: 'student_id' })
  student: Student;

  @ManyToOne(() => Student, { eager: true })
  @JoinColumn({ name: 'student_id' })
  student: Student;


`integer` maps to a 32-bit signed DB column, which overflows for files larger than ~2GB and would throw an out-of-range error when saving. If large archives are expected, use `bigint` (with a matching `number`/`bigint` property type).

  @Column({ name: 'file_size', type: 'bigint', nullable: true })
  fileSize: number;

  @Column({ name: 'file_size', type: 'integer', nullable: true })
  fileSize: number;


Extra blank line between the `mimeType` column and the `status` column; remove it for consistency with the rest of the file.

  mimeType: string;

  @Column({ type: 'varchar', length: 20, default: 'active' })


Redundant union type: `AttendanceDeviceStatus` already has exactly the values `'active'` and `'disabled'`, so `| 'active' | 'disabled'` adds no valid values while weakening the type contract (any plain string literal still type-checks against this column, bypassing the enum). This redundancy is also copied into the controller (`@Query('status') status?: AttendanceDeviceStatus | 'active' | 'disabled'`) and service signatures. Use `AttendanceDeviceStatus` alone so all consumers get the centralized enum type.

status: AttendanceDeviceStatus | 'active' | 'disabled';


The `status` column is stored as plain `varchar`, so invalid values (e.g. 'paused', 'enabled') can be written directly at the DB layer and bypass the enum entirely. Since `AttendanceDeviceStatus` already defines the allowed values, use TypeORM's `enum` column type so the database enforces data integrity, not just the application type.

@Column({
    type: 'enum',
    enum: AttendanceDeviceStatus,
    enumName: 'attendance_device_status',
    default: AttendanceDeviceStatus.ACTIVE,
  })
  status: AttendanceDeviceStatus;

@Column({ type: 'varchar', length: 20, default: AttendanceDeviceStatus.ACTIVE })
  status: AttendanceDeviceStatus | 'active' | 'disabled';


`studentIds` is validated only as a non-empty integer array with no upper bound. A client can submit an arbitrarily large array (e.g., hundreds of thousands of IDs), forcing the service to allocate memory and run a huge batch operation — a resource-exhaustion/DoS vector. Add `@ArrayMaxSize` (e.g., 100) to cap the batch size and import it from class-validator.

  @IsArray()
  @ArrayNotEmpty()
  @ArrayMaxSize(100)
  @IsInt({ each: true })
  studentIds: number[];

  @IsArray()
  @ArrayNotEmpty()
  @IsInt({ each: true })
  studentIds: number[];


Both `paidDate` and `status` are optional in this update DTO, so an empty payload `{}` passes validation and silently becomes a no-op update unless the service explicitly guards against it. Consider enforcing that at least one field is present (e.g., a class-level `@ValidateIf`/custom validator). Also add `@IsString()` on `status` so the type constraint is explicit rather than relying on `IsIn`'s strict comparison.

  @IsOptional()
  @IsString()
  @IsIn(['pending', 'paid', 'overdue'])
  status?: string;

export class UpdateDepositInstallmentDto {
  @IsOptional()
  @IsDateString()
  paidDate?: string;


`studentId` only requires `@IsInt()`, so `0` and negative integers pass validation and would be meaningless DB IDs. Add `@Min(1)` (and `@Min(1, { each: true })` on `studentIds` in `BatchCreateDepositDto`) to reject non-positive IDs.

  @IsInt()
  @Min(1)
  studentId: number;


`BatchCreateDepositDto` duplicates the `amount`, `paidDate`, and `notes` fields (and their validators) from `CreateDepositDto`. Consider extracting a shared base DTO (e.g., `BaseDepositDto`) and extending it so the validation rules stay consistent when amounts/dates change.

export class BatchCreateDepositDto {
  @IsArray()
  @ArrayNotEmpty()
  @IsInt({ each: true })
  studentIds: number[];

  @IsNumber({ maxDecimalPlaces: 2 })
  @Min(0.01)
  amount: number;


Timezone conversion can silently change the date. `dayjs(date).utcOffset(8)` re-renders the parsed instant in UTC+8, so an input like `2026-08-09T20:00:00Z` is normalized to `2026-08-10` — the date rolls over even though the caller supplied an 08-09 date. Worse, for naive strings without an offset (e.g. `2026-08-09T12:00:00`), `new Date` interprets them in the *server's* local timezone, so the same wall-clock input yields different results depending on where the server runs. If the intent is only to strip the time from the original value, return `isoPrefix` directly; otherwise normalize every input to a fixed zone (e.g. `dayjs.utc(value)` / `dayjs(value).utcOffset(8)` consistently) and document the rollover semantics. Also consider extracting the hardcoded offset `8` into a named constant.`

if (!Number.isNaN(date.getTime())) return dayjs(date).utcOffset(8).format('YYYY-MM-DD');


`DATE_ONLY_PATTERN` only checks the shape, not the validity of the date — values like `2026-13-40` or `2026-02-31` pass through unchanged and will be persisted. Since the fast path is returned without any validation, consider strict parsing (e.g. `dayjs(value, 'YYYY-MM-DD', true).isValid()`) or validating the date components before returning.`, the fast path returns the value without any validation, so impossible dates like `2026-13-40` or `2026-02-31` pass through unchanged and get stored. Consider strict parsing (e.g. `dayjs(value, 'YYYY-MM-DD', true).isValid()`) or component-level validation before returning.`

if (DATE_ONLY_PATTERN.test(value)) return value;


The business status value 'in_progress' is hardcoded as a raw magic string. This is a business-related constant that can easily drift from service-side code (e.g., 'completed', 'cancelled' used elsewhere), leading to silent data inconsistencies. Recommend defining a TS enum/union type (e.g., `enum AttendanceSessionStatus`) and using it for both the column type and default.

@Column({ type: 'varchar', length: 20, default: AttendanceSessionStatus.IN_PROGRESS })
  status: AttendanceSessionStatus;

@Column({ length: 20, default: 'in_progress' })
  status: string;


Type mismatch: the column is declared as `type: 'date'` but the property is typed as `string`. Depending on the DB driver (e.g., PostgreSQL returns JS `Date` objects for `date` columns), the runtime value may be a `Date`, breaking string operations/comparisons that assume 'YYYY-MM-DD'. Align the property type with the actual driver return type or use a consistent type across the codebase.

@Column({ name: 'lesson_date', type: 'date' })
  lessonDate: string;


TypeORM (and the underlying MySQL/pg drivers) return `decimal` columns as strings, not numbers. Declaring `roomTotalAmount` as `number` is misleading: consumers will receive a string at runtime, which can silently break arithmetic comparisons or validation. Either declare these as `string` or use a column transformer (e.g., a decimal-to-number transformer) so the TypeScript type matches the actual runtime value.

@Column({ name: 'room_total_amount', type: 'decimal', precision: 10, scale: 2, nullable: true, transformer: { to: (v: number) => v, from: (v: string) => parseFloat(v) } })
  roomTotalAmount: number;

@Column({ name: 'room_total_amount', type: 'decimal', precision: 10, scale: 2, nullable: true })
  roomTotalAmount: number;


Same issue as `roomTotalAmount`: `decimal` columns are returned as strings by TypeORM drivers, so `studentAmount` will not actually be a `number` at runtime. Apply the same transformer/typing fix for consistency.

@Column({ name: 'student_amount', type: 'decimal', precision: 10, scale: 2, nullable: true })
  studentAmount: number;


The column is `nullable: true` but the property type is `number` (non-nullable). This is inconsistent with `roomExpenseId`/`personalExpenseId` above, which correctly use `number | null`. After loading an entity, `roomId` can legitimately be `null`, and code that assumes it is a number may throw a null-pointer-style error. Type it as `number | null` (and consider adding `type: 'integer'` for consistency with the other FK columns).

@Column({ name: 'room_id', type: 'integer', nullable: true })
  roomId: number | null;

@Column({ name: 'room_id', nullable: true })
  roomId: number;


`expenseType` is marked `nullable: true` but typed as non-nullable `string`. The runtime value can be `null`, so the property type should include `null` to avoid null-reference errors at call sites. The same inconsistency applies to `description`, `days`, and `totalRoomDays` in this entity.

@Column({ name: 'expense_type', length: 20, nullable: true })
  expenseType: string | null;

@Column({ name: 'expense_type', length: 20, nullable: true })
  expenseType: string;


`void this.notificationsService.create(...)` discards the returned promise without awaiting or attaching a `.catch()`. The surrounding `try/catch` cannot catch an async rejection here because the promise isn't awaited — a rejection from `notificationsService.create` becomes an **unhandled promise rejection**, which in modern Node.js (default `--unhandled-rejections=throw`) can crash the process. This defeats the stated intent of the comment (`通知失败不影响主流程`). Fix: await it (keeping it inside the try/catch) or add `.catch(() => {})`.

await this.notificationsService.create({ recipientIds: [student.userId], type, title, content });

void this.notificationsService.create({ recipientIds: [student.userId], type, title, content });


`create` (and likewise `refund`) `await notifyDeposit(...)` before returning, which serializes an extra DB lookup (`studentRepo.findOne`) plus notification creation into the critical write-path latency of the response. Since the notification is explicitly fire-and-forget, consider performing the student lookup + notification asynchronously (with proper error handling) or after returning the response, so deposit creation isn't blocked by it.

await this.notifyDeposit(dto.studentId, 'deposit_due', '押金待缴', `您有一笔押金待缴纳,金额: ¥${dto.amount}`);


The installment endpoints use an ad-hoc inline request type (`user?: { id; username }`, `headers?: Record<string, string>`) while all other handlers use the shared `AuthenticatedRequest` interface. This is inconsistent and easy to drift — e.g., `logAudit`/`extractRequestInfo` actually read `headers` as `Record<string, string | string[] | undefined>`. Use `AuthenticatedRequest` here too for consistency.

  async addInstallment(
    @Param('id', ParseIntPipe) id: number,
    @Body() body: CreateDepositInstallmentDto,
    @Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
  ) {


The `ScheduleType` enum is declared at the top of the file but never used — the `schedule_type` column falls back to a plain `string` with a hardcoded `'INTERNAL'` default. This means invalid values (e.g., 'RENTALX') can be persisted with no compile-time or DB-level validation, and the enum becomes dead code. Use the enum for both the column definition and the property type.

  @Column({ name: 'schedule_type', type: 'enum', enum: ScheduleType, default: ScheduleType.INTERNAL })
  scheduleType: ScheduleType;

  @Column({ name: 'schedule_type', length: 20, default: 'INTERNAL' })
  scheduleType: string;


The `status` column stores a hardcoded business string `'active'` with no validation. Invalid statuses can silently be written to the DB. Consider defining a status enum/constant set and validating it (e.g., DB enum or DTO-level validation) to keep business states consistent.

  @Column({ name: 'status', length: 20, default: 'active' })
  status: string;


`notes` is declared `nullable: true` in the DB but typed as non-nullable `string`. Downstream code may assume it is always a string and hit null-pointer issues. The TypeScript type should reflect the column nullability: `notes: string | null;`

  @Column({ name: 'notes', type: 'text', nullable: true })
  notes: string | null;

  @Column({ name: 'notes', type: 'text', nullable: true })
  notes: string;


The `classroom` relation is typed as `unknown`, which discards all type safety for a forward-referenced entity (unlike `class`/`teacher`, which import proper types). Import a `Classroom` type and declare `classroom: Classroom | null;` so consumers get compile-time checks.

  @ManyToOne('Classroom')
  @JoinColumn({ name: 'classroom_id' })
  classroom: unknown;


No data-integrity constraint ensures `end_time` is after `start_time` (or `end_date` >= `start_date`). A schedule with end before start would silently be persisted and could break attendance/time logic. Consider adding `@Check` constraints (e.g., `@Check('end_time > start_time')`) similar to the existing `week_day` check.

  @Column({ name: 'start_time', length: 5 })
  startTime: string;

  @Column({ name: 'end_time', length: 5 })
  endTime: string;


`class_id` is declared `nullable: true` and the relation uses `onDelete: 'SET NULL'`, so `classId` will be `null` at runtime (e.g. after a class is deleted or when no class is assigned). It is typed as `number`, which defeats TypeScript null-safety and can cause runtime null-pointer errors in service code that assumes a non-null value. Use `number | null` for consistency with `scheduleId`/`attendanceSessionId` above.

  @Column({ name: 'class_id', type: 'integer', nullable: true })
  classId: number | null;

  @Column({ name: 'class_id', type: 'integer', nullable: true })
  classId: number;


This unique composite index does not enforce uniqueness when `attendance_session_id` is NULL: in standard SQL (MySQL/PostgreSQL), NULL values are treated as distinct in unique indexes, so the same `student_id` can be inserted multiple times with a NULL session (e.g. manual/standalone records). If the business rule is "one record per student per attendance session", `attendanceSessionId` should be non-nullable for every record, or uniqueness for session-less records must be enforced in application logic. Note that the relation's `onDelete: 'SET NULL'` also converts rows to NULL sessions, which bypasses this constraint.

@Index(['attendanceSessionId', 'studentId'], { unique: true })


`class` is a reserved word in JavaScript. Although legal as an object property name, it breaks destructuring (`const { class } = record` is a SyntaxError) and can confuse tooling/readers. Consider renaming the property (e.g. `classEntity`) while keeping the DB column `class_id` via the existing `@JoinColumn`/`name` mapping.

  @ManyToOne(() => Class, { onDelete: 'SET NULL', nullable: true })
  @JoinColumn({ name: 'class_id' })
  class: Class;


`default: 'manual'` only affects the generated DDL. TypeORM does not populate the in-memory property when an entity is instantiated without `source`, and after `save()` in MySQL the DB-applied default may not be reflected in the returned entity. Code reading `record.source` immediately after creation can get `undefined`. Initialize the property so the runtime value matches the DB default.

  @Column({ name: 'source', length: 20, default: 'manual' })
  source: string = 'manual';

  @Column({ name: 'source', length: 20, default: 'manual' })
  source: string;


The `status` column is stringly-typed with a hardcoded business default `'available'`. The same literal is compared/assigned in many places (e.g., `bed.status !== 'available'` in occupancies/room-bed-locker services, and the bed DTO validates `@IsEnum(['available','occupied','maintenance'])`), so a typo in any one place would silently break business logic. Consider defining a `BedStatus` enum/constant (the codebase already uses `ClassroomStatus` this way) and using it for both the default value and the property type.

  @Column({ type: 'varchar', length: 20, default: 'available' })
  status: string;


The `room_id` column is NOT NULL in the schema (see migration: `room_id` int NOT NULL), but `@ManyToOne` defaults to `nullable: true`. This makes the relation metadata inconsistent with the actual column constraint and suggests `room` can be null when it cannot. Set `nullable: false` on the relation to keep the metadata aligned with the schema.

  @ManyToOne(() => Room, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'room_id' })
  room: Room;


The `@Unique` decorator is separated from the class declaration by a blank line. It still applies to the `Bed` class, but this formatting is misleading (it looks like an orphaned decorator) and is likely to be flagged by formatters/linters. Remove the blank line between the two class decorators.


Potential data loss: `findOne` here matches ANY deposit of the student, including archived ones. Because `purge()` refuses to permanently delete deposits that carry `refundAmount > 0`, archived records with refund history persist. A subsequent `create`/`batchCreate` for that student will then reopen the archived record, overwrite `status` to 'paid', and null out `refundDate/refundAmount/refundedBy/refundedAt` — permanently erasing the historical refund/audit trail. Additionally this check-then-act (findOne followed by save/insert) is not atomic; two concurrent creates for the same new student can both pass the `!existing` branch and produce duplicate deposit rows. Suggest excluding archived records (e.g. `where: { studentId, status: Not('archived') }` and import `Not` from typeorm), or always inserting a fresh record when an archived one exists.

    const existing = await this.repo.findOne({
      where: { studentId: dto.studentId, status: Not('archived') },
    });
    if (existing) {

    const existing = await this.repo.findOne({ where: { studentId: dto.studentId } });
    if (existing) {


No deposit-status guard: installments can be added to a deposit that is already fully refunded (`refunded`, amount=0) or archived, which is inconsistent business logic (staged payments on an already refunded/closed deposit). Add a guard before creating the installment.

    const deposit = await this.repo.findOne({ where: { id: depositId } });
    if (!deposit) throw new NotFoundException('押金记录不存在');
    if (deposit.status === 'refunded' || deposit.status === 'archived') {
      throw new BadRequestException('已退款或已归档的押金不能新增分期');
    }


The loop sequentially awaits `create` for each student, even though these operations are independent (studentIds are deduplicated and don't share state). This serializes N round-trips (student lookup + deposit find + save each) and makes batch creation O(N) in wall-clock time. Use `Promise.all` to run them in parallel.

    const results = await Promise.all(
      studentIds.map((studentId) => this.create({
        studentId,
        amount,
        paidDate: dto.paidDate,
        notes: dto.notes,
      }, userId)),
    );

    for (const studentId of studentIds) {
      results.push(await this.create({


`occupancyRepo` is declared optional (`?`) in the constructor but then force-unwrapped with `!` here. If it is ever undefined (e.g., test setup or a code path where the provider isn't injected), this throws a raw `TypeError` instead of a controlled error. Since `getEligibleStudents` uses it unconditionally, make it a required constructor parameter (drop `?`), or add an explicit null guard with a clear error message.

    if (!this.occupancyRepo) throw new Error('Occupancy repository is not available');
    const qb = this.occupancyRepo


User-supplied `keyword` is interpolated into a LIKE pattern without escaping. Characters `%` and `_` act as wildcards, so a keyword like `%` matches every student, and `_` matches arbitrary characters — leading to unintended broad matches and slight information exposure. Escape `\`, `%`, `_` in the keyword and add an `ESCAPE` clause (or sanitize the keyword to a plain substring match).

      const escaped = query.keyword.replace(/[\\%_]/g, (ch) => `\\${ch}`);
      qb.andWhere(
        '(student.name LIKE :keyword ESCAPE :escape OR student.studentNo LIKE :keyword ESCAPE :escape)',
        { keyword: `%${escaped}%`, escape: '\\' },
      );

      qb.andWhere(
        '(student.name LIKE :keyword OR student.studentNo LIKE :keyword)',
        { keyword: `%${query.keyword}%` },
      );


Inconsistent referential behavior: the `class` relation uses `onDelete: 'CASCADE'`, but the `student` relation has no `onDelete` option (defaults to NO ACTION/RESTRICT at the DB level). Since `student_id` is NOT NULL, deleting a Student that still has rows in `class_student` will throw a foreign-key constraint error at runtime, while deleting a Class silently cascades. Either add `onDelete: 'CASCADE'` here (appropriate for a join table) or align the behavior deliberately (e.g., soft-delete students).

  @ManyToOne(() => Student, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'student_id' })
  student: Student;

  @ManyToOne(() => Student)
  @JoinColumn({ name: 'student_id' })
  student: Student;


Type mismatch: `joinDate`/`leaveDate` are declared as `string | null`, but TypeORM maps `date` columns to `Date` objects at runtime (matching `createdAt: Date` in this same entity). Returning these as strings can cause bugs when the values are passed to `Date` APIs or serialized. Declare them as `Date | null` instead.

  @Column({ name: 'join_date', type: 'date', nullable: true })
  joinDate: Date | null;

  @Column({ name: 'join_date', type: 'date', nullable: true })
  joinDate: string | null;


Using `class` as a property name is risky in JS/TS because it is a reserved word: destructuring (`const { class } = ...`) is a syntax error, and some serialization/tooling handles it poorly. Consider renaming to e.g. `classEntity`/`clazz` or using a non-reserved name to improve maintainability.

  @JoinColumn({ name: 'class_id' })
  class: Class;


The unique constraint `@Unique(['classId', 'studentId'])` creates an index on `(class_id, student_id)`, which does not efficiently serve queries that filter by `student_id` alone (e.g., "all classes of a student"). Add a dedicated index on `student_id` (or use `@Index` on the column) if such lookups are expected.

  @Column({ name: 'student_id', type: 'integer' })
  studentId: number;


TypeORM's MySQL driver returns DECIMAL columns as strings at runtime, not numbers. These money fields (sharedAmount, personalAmount, totalAmount, paidAmount, outstandingAmount) are declared as `number`, so any arithmetic like `paidAmount + outstandingAmount` or `total - paid` will silently do string concatenation (e.g. "10" + "5" = "105") and JSON serialization will yield strings. Add a value transformer (e.g. a shared ColumnNumericTransformer with to/from: Number) to all five decimal columns, or declare them as `string` if kept raw. Note the same pattern exists elsewhere in the codebase, so a shared transformer is advisable.

  @Column({ name: 'shared_amount', type: 'decimal', precision: 10, scale: 2, default: 0, transformer: numericTransformer })
  sharedAmount: number;

  @Column({ name: 'shared_amount', type: 'decimal', precision: 10, scale: 2, default: 0 })
  sharedAmount: number;


The union type `'batch' | 'student_utility'` is compile-time only; the DB column is a plain varchar with no constraint, so any arbitrary string can be persisted (and TypeORM won't validate it on insert/update). Enforce it at the schema level with the `enum` option (works with MySQL) so invalid values are rejected by the DB.

  @Column({ type: 'varchar', length: 30, default: 'batch', enum: ['batch', 'student_utility'] })
  source: 'batch' | 'student_utility';

  @Column({ type: 'varchar', length: 30, default: 'batch' })
  source: 'batch' | 'student_utility';


`roleType` is declared as plain `string` even though the `TeacherRoleType` enum is defined in this same file and used throughout the codebase (classes.controller, DTOs, attendance-query.service). This defeats compile-time type safety — arbitrary strings can be assigned and persisted without TS complaining. Additionally, the column decorator does not enforce the enum at the DB level (`varchar(30)` accepts any string ≤30 chars), so invalid roles like `'headteacher'` would silently be stored. Use the enum for the property type and, ideally, `type: 'enum', enum: TeacherRoleType` in the column decorator to validate at the database layer.

  @Column({ name: 'role_type', type: 'enum', enum: TeacherRoleType, default: TeacherRoleType.SUBJECT_TEACHER })
  roleType: TeacherRoleType;

  @Column({ name: 'role_type', length: 30 })
  roleType: string;


`subject` is declared as non-nullable `string` while the column is `nullable: true`. TypeORM will return `null` at runtime whenever a non-subject teacher has no subject, but TypeScript still treats it as `string`, hiding potential null dereferences at call sites (e.g., `classes.service.ts` forwards `t.subject` directly into DTOs). Declare the type as `string | null` so callers are forced to handle the null case.

  @Column({ name: 'subject', length: 50, nullable: true })
  subject: string | null;

  @Column({ name: 'subject', length: 50, nullable: true })
  subject: string;


The union type is redundant: `ClassroomStatus` is a string enum whose members are exactly `'available' | 'maintenance' | 'archived'`, so the extra literal union adds nothing and makes the type harder to maintain. Additionally, since a status enum is already defined, consider using `type: 'enum', enum: ClassroomStatus` instead of `type: 'varchar'` so the DB enforces valid values (currently any arbitrary string can be stored at the DB level, bypassing TS type checks).

@Column({ type: 'enum', enum: ClassroomStatus, default: ClassroomStatus.AVAILABLE })
  status: ClassroomStatus;

@Column({ type: 'varchar', length: 20, default: ClassroomStatus.AVAILABLE })
  status: ClassroomStatus | 'available' | 'maintenance' | 'archived';


Hardcoded business string `'大'` as the default for room type. This is a business value (大/次大/小) and should be defined as a constant or enum (e.g., `enum RoomType { LARGE = '大', ... }`) to avoid typos and keep business semantics in one place. If the value set ever changes, callers relying on the raw string will silently drift.

@Column({ name: 'room_type', type: 'varchar', length: 20, default: RoomType.LARGE })
  roomType: RoomType; // 大 / 次大 / 小

@Column({ name: 'room_type', type: 'varchar', length: 20, default: '大' })
  roomType: string; // 大 / 次大 / 小


The `ClassType` enum is defined in this file but `classType` is typed as a plain `string`, so invalid values can be assigned/persisted without any compile-time check. Use the enum type for type safety.

  @Column({ name: 'class_type', length: 20 })
  classType: ClassType;

  @Column({ name: 'class_type', length: 20 })
  classType: string;


`status` is typed as `string` even though the `ClassStatus` enum is used for its default value. Declaring it as `ClassStatus` would prevent invalid status values from being stored (e.g., typos like 'actve').

  @Column({ name: 'status', length: 20, default: ClassStatus.ENROLLING })
  status: ClassStatus;

  @Column({ name: 'status', length: 20, default: ClassStatus.ENROLLING })
  status: string;


Defaulting `max_students` to 0 likely means a class created without an explicit capacity would have 0 seats, effectively rejecting all enrollments. If 0 is not a meaningful "unlimited" sentinel, this is a bug — remove the default (require an explicit value) or use a sensible non-zero default / nullable column.

  @Column({ name: 'max_students', type: 'integer', default: 0 })
  maxStudents: number;


TypeORM returns `decimal` columns as strings from the database driver (MySQL/Postgres), so at runtime `amount` will be a `string`, contradicting the `number` type annotation. This can cause silent bugs such as string concatenation in arithmetic (`amount + 5` -> `'10.005'`). Either type the property as `string` or add a `ColumnNumericTransformer` that parses the value to a number.

@Column({ type: 'decimal', precision: 10, scale: 2 })
  amount: number;


The `status` field is free-form varchar and only a comment documents the allowed values, so invalid values can be silently persisted. Consider constraining it with a TS enum (e.g., `enum: ['pending', 'paid']` for MySQL, or a Postgres enum / check constraint) to enforce data integrity.

@Column({ type: 'varchar', length: 20, default: 'pending' })
  status: string; // pending | paid


TypeORM does not automatically create a database index on a `@ManyToOne` join column. Since `depositId` is the FK used to look up installments for a deposit, consider adding `@Index('idx_deposit_installments_deposit_id')` to the `depositId` column to avoid full table scans (especially on Postgres, where FKs do not auto-index).

@Column({ name: 'deposit_id' })
  depositId: number;


This column is declared `nullable: true` in the schema but typed as plain `number`. TypeScript will not surface nullability, so callers may read `lessorOrganizationId` (also `lesseeOrganizationId`, `createdBy`, `dailyRate`, `totalAmount` below) and get `null` at runtime, leading to NPEs/undefined errors. Type these as `number | null` to match the nullable columns.

  @Column({ name: 'lessor_organization_id', nullable: true })
  lessorOrganizationId: number | null;

  @Column({ name: 'lessor_organization_id', nullable: true })
  lessorOrganizationId: number;


TypeORM hydrates `decimal` columns as strings by default (no global transformer is configured in this project). So at runtime `dailyRate`/`totalAmount` will be strings like "1000.00", not numbers — arithmetic (`+`, comparisons) can silently misbehave (e.g. string concatenation). Either type them as `string | null` or attach a transformer that converts the DB value to `Number`. If `number` is kept, also fix the nullability (see comment above).

  @Column({ name: 'daily_rate', type: 'decimal', precision: 10, scale: 2, nullable: true })
  dailyRate: number | null;

  @Column({ name: 'daily_rate', type: 'decimal', precision: 10, scale: 2, nullable: true })
  dailyRate: number;


The union `ClassroomRentalStatus | 'active' | 'ended' | 'cancelled'` is redundant: the three string literals are already members of the `ClassroomRentalStatus` enum. If the enum is later changed, this union can silently drift out of sync. Declare the type as just `ClassroomRentalStatus`.

  @Column({ type: 'varchar', length: 20, default: ClassroomRentalStatus.ACTIVE })
  status: ClassroomRentalStatus;

  @Column({ type: 'varchar', length: 20, default: ClassroomRentalStatus.ACTIVE })
  status: ClassroomRentalStatus | 'active' | 'ended' | 'cancelled';


Type mismatch: `deduction_reason` is `nullable: true`, but the property is typed as non-nullable `string`. Same issue applies to `notes` below. Callers will assume these values are always strings and may not null-check, leading to runtime errors. Type them as `string | null`.

  @Column({ name: 'deduction_reason', type: 'text', nullable: true })
  deductionReason: string | null;

  @Column({ name: 'deduction_reason', type: 'text', nullable: true })
  deductionReason: string;


Type mismatch: TypeORM maps `decimal` columns to `string` values with the default MySQL/Postgres drivers (numbers are returned as strings to preserve currency precision). Declaring `amount: number` (likewise `refundAmount`, `deductionAmount`) is therefore incorrect and can cause silent bugs, e.g. `amount + refundAmount` producing string concatenation instead of arithmetic. Either add a `transformer` that parses to `number`, or type these fields as `string`/`string | null`.

  @Column({ type: 'decimal', precision: 10, scale: 2, default: 500 })
  amount: number;


`eager: true` on both `installments` and `student` means every Deposit load automatically issues additional queries to fetch all installments and the student record. In list/report queries this creates N+1 behavior and can pull large, unnecessary data volumes (especially as installment history grows). Consider dropping `eager` and loading relations explicitly per query via `relations`, or keep eager on at most one lightweight relation.

  @OneToMany(() => DepositInstallment, (i) => i.deposit, { cascade: true, eager: true })
  installments: DepositInstallment[];


`status` is a plain string whose domain values ('paid' | 'refunded' | 'depleted') exist only in a code comment. This allows arbitrary/invalid statuses to be persisted and offers no compile-time safety. Define a TS union type or enum (e.g. `type DepositStatus = 'paid' | 'refunded' | 'depleted'`) and use it for the property type; also consider extracting the hardcoded default `500` and `'paid'` into named constants.

  @Column({ type: 'varchar', length: 20, default: 'paid' })
  status: string;


Redundant/inconsistent design: `refund_date` (a date) and `refunded_at` (a datetime) both track the same refund event, which can easily drift out of sync and forces callers to decide which one is authoritative. Keep a single source of truth (e.g. `refundedAt`) and derive the date from it if needed.

  @Column({ name: 'refund_date', type: 'date', nullable: true })
  refundDate: string | null;


No `onDelete` strategy is specified on the `student` relation. With the default DB behavior (NO ACTION/RESTRICT), deleting a Student who has deposits will fail at the database level, and there is no explicit handling for orphaned deposits. Define an explicit `onDelete` policy (e.g. 'CASCADE'/'SET NULL'/'RESTRICT') in `@JoinColumn` or `@ManyToOne` that matches the intended business rules.

  @ManyToOne(() => Student, { eager: true })
  @JoinColumn({ name: 'student_id' })


Hardcoded business string 'room' is used as the default for the `category` column. Per the review rules, business-related hardcoded strings should be avoided — if `room` is a business category value it should be defined as a shared constant or enum (e.g., `export const EXPENSE_CATEGORY = { ROOM: 'room', ... } as const`) so it can be reused and validated consistently across the codebase.

@Column({ length: 20, default: 'room' })


`JSON.stringify` returns `string | undefined`; if `mappings` is ever `undefined` (e.g., the entity is constructed without it), this returns `undefined` rather than a string, and the value will be written as NULL into a non-nullable `text` column, which can cause save failures or silently corrupt data. Recommend normalizing the input first: `return JSON.stringify(value ?? {});`

  to(value: JinshujuFieldMapping): string {
    return JSON.stringify(value ?? {});
  },

  to(value: JinshujuFieldMapping): string {
    return JSON.stringify(value);
  },


`JSON.parse` here can throw a SyntaxError if the DB row contains invalid/corrupted JSON (manual edits, partial writes, or data migration issues), which would break every entity read for the whole table. Additionally, if the stored string is `"null"`, `JSON.parse` returns `null` (not an object) and the later cast masks it, so downstream code accessing `rule.mappings.name` would hit a null pointer error. Consider wrapping the parse in try/catch and validating the result, e.g. fall back to `{}` on failure or when the parsed value is not an object.

  from(value: string | null): JinshujuFieldMapping {
    if (!value) return {};
    try {
      const parsed = JSON.parse(value);
      return (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
        ? (parsed as JinshujuFieldMapping)
        : {};
    } catch {
      return {};
    }
  },

  from(value: string | null): JinshujuFieldMapping {
    if (!value) return {};
    return JSON.parse(value) as JinshujuFieldMapping;
  },


The unique constraint is declared twice for `operationId`: once at the entity level via `@Index(['operationId'], { unique: true })` and again via `unique: true` on the column. TypeORM will generate two separate unique indexes/constraints on the same column, which is redundant (wasted storage on index writes) and causes unnecessary schema/migration churn. Keep only one — recommend removing the entity-level `@Index` and keeping `unique: true` on the column (or vice versa).

@Index(['operationId'], { unique: true })
export class FinancialOperation {


`status` is typed as the TS union `FinancialOperationStatus` but the DB column is a plain `varchar`, so the union is only enforced at compile time. Invalid status values (e.g. 'pending') can be silently persisted. Use the TypeORM `enum` column type (e.g. `type: 'enum', enum: ['running', 'completed', 'failed']`) to enforce data integrity at the database level, or at least validate before persisting.

@Column({ type: 'enum', enum: ['running', 'completed', 'failed'], default: 'running' })
  status: FinancialOperationStatus;

@Column({ type: 'varchar', length: 20, default: 'running' })
  status: FinancialOperationStatus;


`resultJson` stores JSON payloads as a plain `text` column, which forfeits DB-level JSON validation and the ability to query/navigate the JSON structure. If the backing database supports it (Postgres `jsonb`/`json`, MySQL `json`), prefer the native JSON column type and type the property accordingly (e.g. `Record<string, unknown> | null`) instead of `string | null`.

@Column({ name: 'result_json', type: 'jsonb', nullable: true })
  resultJson: Record<string, unknown> | null;

@Column({ name: 'result_json', type: 'text', nullable: true })
  resultJson: string | null;


Type mismatch: the column is `nullable: true` and the relation uses `onDelete: 'SET NULL'`, so `matchedStudentId` will legitimately be `null` for unmatched records (confirmed by usage such as `raw.matchedStudentId == null` in attendance-lesson.service.ts). Declaring it as `number` lets TypeScript assume it is never null, defeating null-safety checks and risking NPEs/undefined behavior when accessing it. Declare it as `number | null`.

  @Column({ name: 'matched_student_id', type: 'integer', nullable: true })
  matchedStudentId: number | null;

  @Column({ name: 'matched_student_id', type: 'integer', nullable: true })
  matchedStudentId: number;


Since the FK column can be null (`onDelete: 'SET NULL'`, `nullable: true`), the relation property should be typed as `Student | null`. With `matchedStudent: Student`, TS assumes the object is always present and won't flag accesses like `record.matchedStudent.name` without a null check, which can cause runtime errors for unmatched records.

  @ManyToOne(() => Student, { onDelete: 'SET NULL', nullable: true })
  @JoinColumn({ name: 'matched_student_id' })
  matchedStudent: Student | null;

  @ManyToOne(() => Student, { onDelete: 'SET NULL', nullable: true })
  @JoinColumn({ name: 'matched_student_id' })
  matchedStudent: Student;


`workDate` and `matchStatus` are defined as two separate single-column indexes. Typical queries in this domain filter on both together (e.g., finding unmatched records for a given date during the matching process), where two separate indexes cannot be combined efficiently. Consider a composite index `@Index(['workDate', 'matchStatus'])` (and drop the standalone ones if not needed elsewhere) to speed up those lookups.


`matchStatus` is a business status field stored as a free-form string with default 'unmatched'. Magic strings like 'unmatched'/'matched' are compared across services and can silently drift due to typos. Consider a union type (`'unmatched' | 'matched' | ...`) or a TS enum to keep values centralized and type-safe.

  @Column({ name: 'match_status', length: 20, default: 'unmatched' })
  matchStatus: string;


Type/nullability mismatch: the DB column `enrollment_id` is defined as `nullable: true`, but the property is typed as non-nullable `number`. Since the relation to StudentEnrollment is optional, this value can legitimately be null, and callers may dereference it assuming it always exists, causing runtime null-pointer errors. Change the type to `number | null` to match the schema.

  @Column({ name: 'enrollment_id', type: 'integer', nullable: true })
  enrollmentId: number | null;

  @Column({ name: 'enrollment_id', type: 'integer', nullable: true })
  enrollmentId: number;


These three columns are declared `nullable: true` in the schema, but the TypeScript properties are typed as non-nullable `string`. Reading an exam score row with no exam type/name/subject will yield `null` at runtime, contradicting the declared types and risking null-pointer bugs in consumers. Type them as `string | null` (or drop `nullable: true` if the values are truly always required).

  @Column({ name: 'exam_type', length: 50, nullable: true })
  examType: string | null;

  @Column({ name: 'exam_name', length: 100, nullable: true })
  examName: string | null;

  @Column({ length: 50, nullable: true })
  subject: string | null;

  @Column({ name: 'exam_type', length: 50, nullable: true })
  examType: string;

  @Column({ name: 'exam_name', length: 100, nullable: true })
  examName: string;

  @Column({ length: 50, nullable: true })
  subject: string;


The relation is declared `nullable: true` (matching the optional `enrollment_id` column), but the property type is non-nullable `StudentEnrollment`. When an exam score has no enrollment, TypeORM will load `null` here, contradicting the declared type. Use `StudentEnrollment | null` for type safety.

  @ManyToOne(() => StudentEnrollment, { nullable: true })
  @JoinColumn({ name: 'enrollment_id' })
  enrollment: StudentEnrollment | null;

  @ManyToOne(() => StudentEnrollment, { nullable: true })
  @JoinColumn({ name: 'enrollment_id' })
  enrollment: StudentEnrollment;


`eager: true` on this ManyToOne means the Student row is always joined and loaded on every ExamScore query (including list queries), even when the student data is not needed — this can noticeably degrade performance on a potentially large exam_scores table and enlarge response payloads. Consider removing `eager: true` and explicitly using `relations: { student: true }` in queries where the student is actually required.

  @ManyToOne(() => Student, { eager: true })
  @JoinColumn({ name: 'student_id' })
  student: Student;


Nullable DB columns are declared with non-nullable TypeScript types: `checkInTime`/`checkOutTime` (`Date`), `locationResult` (`string`), `rawData` (`string`) and `matchedStudentId` (`number`) are all `nullable: true` in the schema but typed without `| null`. This is inconsistent with `punchSource`/`punchDeviceName`/`punchDeviceId` which correctly use `string | null`, and can cause runtime null-pointer errors when business code assumes these values are always present. Type them as `Date | null`, `string | null`, `number | null` to match the schema.

  @Column({ name: 'check_in_time', type: 'datetime', nullable: true })
  checkInTime: Date;


`matchStatus` is a low-cardinality column (few distinct values such as 'unmatched'/'matched'), so a standalone index on it is unlikely to be selected by the query planner and only adds write overhead on every insert/update. If queries filter by status together with attendance date or student, prefer a composite index (e.g. `@Index(['attendanceDate', 'matchStatus'])`) and drop this standalone index; otherwise remove it entirely.


`student_id` is mapped twice: once by the raw `@Column` (`studentId`) and once by the `@ManyToOne` + `@JoinColumn` relation (`student`). When saving a LearningRecord, TypeORM will include the `student_id` column for both properties, which can produce an SQL error ("column student_id specified more than once") or cause the relation FK to be written unexpectedly. Keep only the relation and access the FK via `record.student.id`, or if a raw accessor is genuinely needed, declare it as `@Column({ name: 'student_id', insert: false, update: false })` and let the relation own writes.

@Column({ name: 'student_id', type: 'integer' })
studentId: number;


`eager: true` forces every LearningRecord query to JOIN and load the full Student row even when only `studentId` is needed, adding avoidable query cost (and it can be especially wasteful in list queries). Prefer lazy loading with an explicit `relations: ['student']` (or QueryBuilder) at the call sites that actually need the student. Additionally, the relation lacks an `onDelete` behavior — deleting a Student will leave orphaned `student_id` values or fail the FK constraint; specify `onDelete: 'CASCADE'` or `'RESTRICT'` to define the intended semantics.

@ManyToOne(() => Student, { eager: true })


`record_date` is typed as `string` while `createdAt`/`updatedAt` are typed as `Date`. TypeORM's `date` column type is returned as a `Date` object on some drivers (e.g., PostgreSQL), so the declared type is inaccurate and can lead to incorrect runtime handling. Use `Date` for consistency, or keep `string` only if the driver contract guarantees a string.

@Column({ name: 'record_date', type: 'date', nullable: true })
recordDate: string;


No indexes are declared on the columns most likely used to filter records (`student_id`, `record_date`). If queries commonly fetch records by student or by date range, add `@Index()` to these columns (or a composite index on `(student_id, record_date)`) to avoid full-table scans as the table grows.


Columns declared as `nullable: true` are typed as non-nullable (`string`, `Date`). Under TypeScript `strictNullChecks` this is a type mismatch, and at runtime `content`, `link`, and `readAt` can actually be `null`. Declare them as `string | null` / `Date | null` (and handle null on read) so the entity reflects the real database schema.

  @Column({ name: 'content', type: 'text', nullable: true })
  content: string | null;

  @Column({ name: 'link', length: 500, nullable: true })
  link: string | null;

  @Column({ name: 'read_at', type: 'datetime', nullable: true })
  readAt: Date | null;

  @Column({ name: 'content', type: 'text', nullable: true })
  content: string;

  @Column({ name: 'link', length: 500, nullable: true })
  link: string;

  @Column({ name: 'read_at', type: 'datetime', nullable: true })
  readAt: Date;


`NotificationType` enum is defined above but never used here — the `type` column is typed as plain `string`, so any arbitrary string can be persisted and consumers lose the enum's type safety. Use the enum as the property type (and optionally `type: 'enum'` / `enum: NotificationType` in the column decorator) to enforce valid values.

  @Column({ name: 'type', length: 30 })
  type: NotificationType;

  @Column({ name: 'type', length: 30 })
  type: string;


No index is defined on `recipient_id` or `is_read`. Notifications are typically queried by recipient and filtered by read/unread status (e.g., "unread notifications for user X"), so without an index these lookups will scan the table. Add indexes (at minimum on `recipient_id`, ideally a composite index on `(recipient_id, is_read)`) to keep these queries efficient as the table grows.

  @Index()
  @Column({ name: 'recipient_id', type: 'integer' })
  recipientId: number;


The `class_id` column is declared twice (via `@Column` and via `@JoinColumn`), and `@ManyToOne` is given no options. TypeORM defaults ManyToOne join columns to `nullable: true`, which conflicts with the non-nullable entity types (`classId: number`, `class: Class`) and can produce a nullable FK in the schema — leading to runtime NPEs (e.g. `exam.class.name`) if `class_id` is ever NULL. Additionally, no `onDelete` is configured, so deleting a `Class` that has exams will fail with a foreign-key constraint error (or leave orphaned rows depending on the DB). Declare the relation explicitly and keep a single source of truth for the column.

  @Column({ name: 'class_id', type: 'integer' })
  classId: number;

  @ManyToOne(() => Class, { nullable: false, onDelete: 'RESTRICT' })
  @JoinColumn({ name: 'class_id' })
  class: Class;

  @Column({ name: 'class_id', type: 'integer' })
  classId: number;

  @ManyToOne(() => Class)
  @JoinColumn({ name: 'class_id' })
  class: Class;


`class` is a JavaScript reserved word. It is legal as an object property name, but it breaks common destructuring patterns (`const { class } = exam` is a SyntaxError) and can confuse tooling/serialization layers. Consider renaming the property (e.g. `classEntity` / `klass`) while keeping the underlying `class_id` column name unchanged.


The column is defined as `nullable: true`, but the TypeScript property is declared as a non-nullable `string`. TypeORM will return `null` for rows without a check-out, so downstream code can dereference `null` without any compile-time warning. Apply the same fix to the other nullable scalar fields: `billingEndDate`, `checkOutReason`, `notes`, `bedId`, `lockerId`, `responsibleOrganizationId`.

  @Column({ name: 'check_out_date', type: 'date', nullable: true })
  checkOutDate: string | null;

  @Column({ name: 'check_out_date', type: 'date', nullable: true })
  checkOutDate: string;


`bed_id` is `nullable: true` but `bedId` is typed as plain `number`, so `null` values returned by the database won't be reflected in the type. Also `date`-typed columns are declared as `string`, while TypeORM (especially on PostgreSQL) typically returns `Date` objects — the type should match the actual runtime value.

  @Column({ name: 'bed_id', type: 'integer', nullable: true })
  bedId: number | null;

  @Column({ name: 'bed_id', type: 'integer', nullable: true })
  bedId: number;


This relation is nullable (`{ nullable: true }`), but the property is typed as the non-nullable `Bed`. The same issue applies to `locker` and `responsibleOrganization`. Additionally, `bed_id` is already declared as a standalone `@Column` above; having both a `@Column` and a `@JoinColumn` for the same DB column is redundant and can cause schema-sync/migration conflicts in TypeORM. Prefer keeping only the relation and its `@JoinColumn` (optionally with `{ select: false }` on the FK if a scalar accessor is required).

  @ManyToOne(() => Bed, { nullable: true })
  @JoinColumn({ name: 'bed_id' })
  bed: Bed | null;

  @ManyToOne(() => Bed, { nullable: true })
  @JoinColumn({ name: 'bed_id' })
  bed: Bed;


`student_id` and `room_id` are frequently used in joins and queries (e.g., filtering occupancies by student or room), but no index is defined. The same applies to `bed_id`, `locker_id`, and `responsible_organization_id`. Without indexes, TypeORM will perform sequential scans on the `occupancies` table, which degrades as data grows. Consider adding `@Index()` on these FK columns (and possibly `status`/`check_in_date` for active-occupancy lookups).

  @Index()
  @Column({ name: 'student_id' })
  studentId: number;


`stayType` is declared as a generic `string` with a DB default of `'short'`, while `status` uses a proper union type. If the allowed values are a fixed set (e.g., `'short' | 'long'`), typing the property as a union improves compile-time safety and prevents invalid values being persisted. Also note `length: 10` will reject any future value longer than 10 characters at the DB level.

  @Column({ name: 'stay_type', length: 10, default: 'short' })
  stayType: 'short' | 'long';

  @Column({ name: 'stay_type', length: 10, default: 'short' })
  stayType: string;


`status` is typed as `'active' | 'archived'` but the column only has a DB default of `'active'` with no application-level validation. Since `status` is business-critical (drives filtering of active/archived occupancies), consider adding an application-level check or enum validation (e.g., TypeORM `enum` type) to prevent invalid values from being persisted, and to guarantee the union type actually holds at runtime.

  @Column({ type: 'enum', enum: ['active', 'archived'], default: 'active' })
  status: 'active' | 'archived';

  @Column({ type: 'varchar', length: 20, default: 'active' })
  status: 'active' | 'archived';


The `status` field is a plain `string` with a hardcoded magic-string default `'available'`. Locker status values ('available'/'occupied'/'maintenance') are business domain values used across the app (e.g., `rooms/dto/locker.dto.ts` validates with `@IsEnum(['available', 'occupied', 'maintenance'])`, and services check `locker.status !== 'available'`). With a bare varchar column and no constraint, invalid values (e.g., a typo like 'availble') can silently persist, and there is no type safety at the entity level. Follow the existing `ClassroomStatus` enum pattern in the codebase: define `export enum LockerStatus { AVAILABLE = 'available', OCCUPIED = 'occupied', MAINTENANCE = 'maintenance' }`, type the column with it, and use `LockerStatus.AVAILABLE` as the default (optionally `type: 'enum'` for DB-level enforcement).

@Column({ type: 'enum', enum: LockerStatus, default: LockerStatus.AVAILABLE })
  status: LockerStatus;

@Column({ type: 'varchar', length: 20, default: 'available' })
  status: string;


Operation log tables accumulate large volumes of rows and are typically queried/filtered by userId, targetId, and createdAt. Without indexes on these columns, such queries will degrade to full table scans as the table grows. Consider adding @Index() to userId, targetId, and createdAt (or a composite index covering the most common query pattern).

  @Column({ name: 'user_id', nullable: true })
  userId: number;


Columns `contactName`, `phone`, `color`, and `notes` are defined as `nullable: true` in the DB schema but typed as non-nullable `string`. TypeORM does not automatically widen property types to `string | null`, so under `strictNullChecks` the compiler will not warn when these fields are accessed, and dereferencing a NULL DB value (e.g., `org.contactName.trim()`) will throw a runtime error. Declare them as `string | null` to reflect the actual schema and force callers to perform null checks.

  @Column({ name: 'contact_name', length: 50, nullable: true })
  contactName: string | null;

  @Column({ name: 'contact_name', length: 50, nullable: true })
  contactName: string;


The default `'active'` is a business magic string embedded in the decorator, and the union type `'active' | 'archived'` is only a compile-time hint — the DB column has no CHECK constraint and no shared constant ties the type to the default, so a typo in one place silently diverges from the other (and invalid values can be persisted). Extract the status values into a shared enum/constant and reference it both in the default and the type; consider adding a CHECK constraint at the DB level for integrity.

  @Column({ type: 'varchar', length: 20, default: OrganizationStatus.ACTIVE })
  status: OrganizationStatus;


`group` is a reserved keyword in SQL (MySQL 8.0 lists GROUP as a reserved word). Every query that touches this column must escape it — note the initial migration already has to write `` `group` `` with backticks, and any future raw SQL / query-builder code that forgets to escape will fail with a syntax error. This makes the entity fragile and error-prone. Recommend mapping to a non-reserved DB column name (e.g., `name: 'permission_group'`) while keeping the entity property, and adding a column-rename migration.

@Column({ type: 'varchar', length: 30, name: 'permission_group' })
group: string;

@Column({ type: 'varchar', length: 30 })
group: string;


`importKey` gets a unique constraint from the column-level `unique: true` AND a separate unique index from the class-level `@Index(['importKey'], { unique: true })`. TypeORM will emit two identical unique indexes on the same column, which adds redundant DDL and index-maintenance overhead on every insert/update. Keep only one of them (e.g., remove the column-level `unique: true` and keep the class-level index, or vice versa).

@Index(['importKey'])
export class RoomExpense {

@Index(['importKey'], { unique: true })
export class RoomExpense {


The most common query pattern for this entity is fetching all expenses of a room (`WHERE room_id = ?`). The `room_id` column is only covered by the FK of the `ManyToOne` relation (and FKs don't always create an index, e.g., in Postgres/SQLite). Add an explicit index on `room_id` to avoid full-table scans as the table grows.

  @Column({ name: 'room_id' })
  @Index()
  roomId: number;

  @Column({ name: 'room_id' })
  roomId: number;


`amount` is declared as `number`, but with `type: 'decimal'` most database drivers (notably MySQL) return the value as a string, and TypeORM does not transform it back to a number by default. Arithmetic on this property (`expense.amount + 1`) can silently concatenate strings at runtime, and JS `number` cannot precisely represent fixed-point decimals anyway. Add a `transformer` to convert between string/number on read/write, or document and consistently handle the string type.

  @Column({ type: 'decimal', precision: 10, scale: 2 })
  amount: number;


`decimal(5,2)` columns are typed as `number`, but both MySQL and PostgreSQL drivers return DECIMAL/NUMERIC values as strings at runtime (e.g. `"87.50"`). Any arithmetic or comparisons on `cultureFinalScore`/`professionalFinalScore` will get string coercion behavior instead of numeric. Add a transformer that converts to `parseFloat` on read (and back on write), or declare the property as `string` to match the driver.

  @Column({
    name: 'culture_final_score',
    type: 'decimal',
    precision: 5,
    scale: 2,
    nullable: true,
    transformer: {
      to: (value: number | null) => value,
      from: (value: string | null) => (value === null ? null : parseFloat(value)),
    },
  })
  cultureFinalScore: number;

  @Column({ name: 'culture_final_score', type: 'decimal', precision: 5, scale: 2, nullable: true })
  cultureFinalScore: number;


`student_id` is declared `unique: true`, so each student can have at most one archive — this is effectively a one-to-one relationship, but it is modeled as `@ManyToOne`. Using `@OneToOne(() => Student, { eager: true })` would express the intent correctly and also allow options like `onDelete: 'CASCADE'` so an archive is removed with its student.

  @OneToOne(() => Student, { eager: true, onDelete: 'CASCADE' })
  @JoinColumn({ name: 'student_id' })
  student: Student;

  @ManyToOne(() => Student, { eager: true })
  @JoinColumn({ name: 'student_id' })
  student: Student;


`eager: true` makes TypeORM load the full `Student` row (with all of its columns) on every query for a `ResultArchive`, including list queries where the student object may not be needed. Consider removing `eager` and loading the relation explicitly (e.g. via the `relations` option in find) only where it is actually used.

  @ManyToOne(() => Student)
  @JoinColumn({ name: 'student_id' })
  student: Student;


`code` is declared both `unique: true` and `nullable: true`. A UNIQUE constraint treats NULLs as distinct, so multiple roles can end up with a NULL `code`, which defeats the purpose of the unique business key. If `code` is meant to be the stable identifier used for RBAC/permission checks (as opposed to the display `name`), it should be `nullable: false`; if it is genuinely optional, the uniqueness requirement and the lookup logic relying on it should be reconsidered.

  @Column({ type: 'varchar', length: 30, unique: true })
  code: string;

  @Column({ type: 'varchar', length: 30, unique: true, nullable: true })
  code: string;


Neither ManyToMany relation defines a cascade/delete strategy. TypeORM's generated junction tables (`role_permissions`, `user_roles`) get FK columns without `ON DELETE CASCADE` by default. While `repository.remove()` cleans up junction rows first, any bulk/raw deletion (e.g. `queryBuilder().delete()`, direct SQL) of a Role will fail with a foreign-key constraint error, and users are not protected from orphaned `user_roles` rows. Consider adding an explicit `onDelete`/cascade policy on the join columns if deletion of roles is expected.

  @ManyToMany(() => Permission)
  @JoinTable({
    name: 'role_permissions',
    joinColumn: { name: 'role_id', referencedColumnName: 'id' },
    inverseJoinColumn: { name: 'permission_id', referencedColumnName: 'id' },
  })


The `status` column uses a magic value (`default: 1` = enabled?) with no documented semantics. Since status values drive authorization/visibility logic, define a constants/enum (e.g. `RoleStatus`) with explicit meanings and reference it here and in service code to avoid silent inconsistencies.

  @Column({ type: 'tinyint', default: 1 })
  status: number;


Inconsistent referential actions: the `bed` relation uses `onDelete: 'SET NULL'` and the entity stores `studentNameSnapshot`/`bedNumberSnapshot` specifically to preserve history after referenced records change/are removed, yet `student` and `occupancy` use `onDelete: 'RESTRICT'`. This means a student or occupancy that has ever been inspected can never be hard-deleted from the database, which blocks record cleanup (e.g., removing a checked-out student's history) and contradicts the snapshot-based design. Consider `SET NULL` on these relations as well, or document that hard-deletion of students/occupancies with inspection history is intentionally forbidden.

  @ManyToOne(() => Student, { onDelete: 'RESTRICT' })
  @JoinColumn({ name: 'student_id' })
  student: Student;