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.

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


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;


`status` is constrained only by the TypeScript union type (`'present' | 'absent'`) but is persisted as a plain `varchar(20)` with no database-level constraint. Any code path that bypasses TypeScript checking (raw SQL, migrations, future callers) can silently write an invalid status, and the `RoomInspectionStatus` type must be kept in sync manually. Consider using a database `enum` type or a CHECK constraint (e.g., `status IN ('present', 'absent')`) to enforce valid values at the DB layer.

  @Column({ type: 'varchar', length: 20 })
  status: RoomInspectionStatus;


Type mismatch: TypeORM maps `type: 'decimal'` to a string in MySQL drivers (DECIMAL/NUMERIC columns are returned as strings at runtime, not numbers). Declaring the property as `number` is therefore inaccurate — arithmetic like `monthlyRate * days` will do string concatenation, and JSON serialization will emit "1500.00" instead of 1500. This is already visible in the codebase: bills.service.spec.ts has to cast `'800' as unknown as number` to fit this type. Either declare the field as `string`, or use a transformer that converts to/from `number` (e.g. `{ to: (v) => v, from: (v) => parseFloat(v) }`).

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


Business values ('available', 'short') are hardcoded as bare strings with no constraint at the DB or type level, so any typo or invalid value (e.g. 'avaliable', 'long') silently persists. Consider using a dedicated enum type: `@Column({ type: 'enum', enum: ['available', 'occupied', ...], default: 'available' })` and a matching TS union type for `status`, and similarly for `rental_category` (e.g. 'short' | 'long'). This prevents invalid states and makes the allowed values self-documenting.


Type mismatch: DECIMAL columns are returned as strings by the MySQL/Postgres drivers, so `balance` will actually be a `string` at runtime even though it's typed `number`. This can cause silent bugs like string concatenation instead of arithmetic (e.g., `balance + amount`). Either declare the property as `string`, or add a transformer that converts the DB value to a number:

```ts
@Column({
  type: 'decimal',
  precision: 12,
  scale: 2,
  default: 0,
  transformer: {
    to: (v: number) => v,
    from: (v: string) => parseFloat(v),
  },
})
balance: number;
```

  @Column({ type: 'decimal', precision: 12, scale: 2, default: 0 })
  balance: number;


The `student_id` database column is declared twice: once explicitly via `@Column({ name: 'student_id' })` on `studentId`, and once implicitly via `@JoinColumn({ name: 'student_id' })` on the relation. TypeORM will register duplicate column metadata for the same DB column, which can lead to "Duplicate column" errors during schema sync/migrations and makes the two mappings drift apart. Prefer keeping only the relation and exposing the FK via `@RelationId`, or keep the explicit column and drop the `@JoinColumn` name duplication (e.g., let the relation own the column).

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


`eager: true` makes TypeORM automatically join and load the full Student record on EVERY StudentEnrollment query, including list/batch queries where the student data may not be needed. If Student has many columns or enrollment lists are large, this causes unnecessary joins, extra data transfer, and slower queries. Prefer explicit loading (`relations: ['student']` in find options or QueryBuilder `leftJoinAndSelect`) only where student data is actually required.

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


`student_id` is a foreign key that will typically be used to filter enrollments by student. No index is defined on it. While MySQL/InnoDB auto-creates an index for FK columns, PostgreSQL and several other databases do not, so lookups by student will result in full table scans as data grows. Add `index: true` to the column or an `@Index()` on the property.

@Column({ name: 'student_id', type: 'integer', index: true })

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


The `type: 'date'` columns are declared with TypeScript type `string`, but the runtime value returned by the driver depends on the database: MySQL returns date strings, whereas PostgreSQL (node-postgres) returns `Date` objects for DATE columns. If downstream code assumes `startDate`/`endDate` is always a string (e.g., calls `.slice()` or string comparison), it can fail at runtime. Align the declared type with the actual driver return value (e.g., `Date`) or normalize the value on read.

  @Column({ name: 'start_date', type: 'date', nullable: true })
  startDate: Date;

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


The business state `'active'` is hardcoded as a magic string in the column default. If the set of valid statuses changes or the value needs to be referenced/compared elsewhere (e.g., filtering active enrollments), a magic string invites typos and drift. Consider defining a shared status enum/constant and referencing it here.

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


DECIMAL columns are returned as strings by the MySQL/PostgreSQL drivers; TypeORM does not auto-cast `decimal` to a JS number. Typing `amount` as `number` is therefore a type mismatch — at runtime it will be a string, so arithmetic (`+`), comparisons, and JSON serialization behave incorrectly (e.g., string concatenation). Type it as `string`, or add a transformer, e.g. `transformer: { to: (v) => v, from: (v) => parseFloat(v) }`.


`room_id` is declared `nullable: true`, but the property is typed as a non-nullable `number`; the same applies to `recorded_by` (`recordedBy: number`) and `description` (`string`). At runtime these fields can legitimately be `null`, which silently violates the TS contract and can cause null-pointer/type errors downstream. Note `billId` already uses the correct `number | null` — align these nullable columns with `number | null` / `string | null`.

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


The database column `student_id` is mapped twice: once by the explicit `@Column({ name: 'student_id' })` and again by `@JoinColumn({ name: 'student_id' })` on the `student` relation. Depending on the TypeORM version this can raise a duplicate-column metadata error or cause the FK column to be emitted twice in generated SELECT/INSERT statements. Prefer a single source of truth — either keep the explicit `studentId` column and remove `@JoinColumn`, or keep the relation and drop the explicit `@Column` (accessing the FK through `student`).

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


Type mismatch: `inspectionDate` is declared as `string`, but this is a MySQL `date` column and the app does not configure `dateStrings` in its DataSource options (mysql2 defaults to returning JS `Date` objects). At runtime the hydrated property is a `Date`, so when entities are returned via `getByRoomsAndDate()` and JSON-serialized, values come out like `2026-08-08T16:00:00.000Z` (in Asia/Shanghai this shifts the calendar date to the previous day) instead of the `YYYY-MM-DD` string the rest of the code (e.g. `assertToday`, string date comparisons, frontend) expects. Align the property type with the runtime value (declare `Date` and convert explicitly), or add a column `transformer` that normalizes to a plain `YYYY-MM-DD` string.

  @Column({ name: 'inspection_date', type: 'date' })
  inspectionDate: string;


The `'manual' | 'automatic'` union for `source` is enforced only at compile time; the column is a plain `varchar(20)` with no database-level validation, so any invalid value would be silently persisted and would then break code that branches on `source` (e.g. `settleDate` looks up `source: 'manual'`). Consider declaring the column as `type: 'enum'` with the two values (or adding a CHECK constraint) so the DB enforces the invariant.

  @Column({ type: 'varchar', length: 20, default: 'manual' })
  source: RoomInspectionSource;


This table accumulates one row per sync run and is queried frequently by `SyncService.getLogs`/`getLastSync` with `where: { platform }` plus `order: { createdAt: 'DESC' }`. There is currently no index on `platform` or `created_at` (confirmed in migration `1784520727860-InitialSchema.ts`), so these lookups will degrade into a full table scan + filesort as the log table grows. Consider adding a composite index, e.g. `@Index('IDX_sync_logs_platform_created', ['platform', 'createdAt'])` on the entity (and a matching migration).

@Column({ type: 'varchar', length: 20 })
  platform: SyncPlatform;


This column is `nullable: true` in the DB but typed as non-nullable `string` in TypeScript. The same problem affects `lastLoginAt: Date` and `profile: {...}` below. TypeORM will return `null` for these columns, so code that directly accesses them (e.g. `user.lastLoginAt.toISOString()` or `user.profile.subjects`) can throw at runtime. The TS types should reflect nullability: `name: string | null`, `lastLoginAt: Date | null`, `profile: { ... } | null`.

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

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


`password_hash` is a sensitive field and is included in the default query result set. If a `User` entity is returned directly by a service/controller (or serialized via `class-transformer`/`JSON.stringify`), the password hash can leak to API consumers. Consider adding `select: false` to this column and explicitly selecting it only where needed (e.g. in the authentication flow), or excluding it during serialization.

  @Column({ name: 'password_hash', length: 255, select: false })
  passwordHash: string;

  @Column({ name: 'password_hash', length: 255 })
  passwordHash: string;


These commented-out column declarations are dead code. If they document a schema migration, consider recording the change in a migration file or changelog instead of leaving commented-out code in the entity, so the entity definition stays clean and the DB schema is version-controlled.

  // 移除: @Column({ type: 'varchar', length: 20, default: 'operator' })
  // role: string;


The `user_id` column is defined twice: once explicitly via `@Column` and once implicitly generated by `@JoinColumn({ name: 'user_id' })`. Since `synchronize` defaults to true in app.module.ts, having both definitions is risky (can cause "duplicate column" errors during schema sync) and leaves two sources of truth for the same column. Also, `unique: true` is redundant — a OneToOne owning side already creates a unique constraint on the join column. Prefer keeping only the relation (and access the FK value via `student.user.id`), or drop the `@JoinColumn` duplicate.

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

  @OneToOne(() => User, { nullable: true, onDelete: 'SET NULL' })
  @JoinColumn({ name: 'user_id' })
  user: User;


Same issue as `user_id`: the `organization_id` column is declared twice — once via `@Column` and once via `@JoinColumn({ name: 'organization_id' })` on the `@ManyToOne`. With `synchronize: true`, TypeORM can generate conflicting column definitions. Additionally, `organization_id` is a frequently queried FK and has no index (TypeORM does not always auto-index FK columns); consider adding `@Index` if queries filter by organization.

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

  @ManyToOne(() => Organization, { nullable: true })
  @JoinColumn({ name: 'organization_id' })
  organization: Organization;


`id_number` stores the national ID (a highly sensitive piece of PII) in plaintext in the database. Anyone with DB access can read it, and a leak exposes irreversible personal data. Consider encrypting it at rest (e.g., application-level AES) or storing only a hashed value if full plaintext is not strictly needed, and restrict read access.

  @Column({ name: 'id_number', length: 30, nullable: true })
  idNumber: string;


Nullability mismatch: `student_id` is declared via `@Column` without `nullable`, so it is NOT NULL in the DB, but TypeORM relations default to `nullable: true`. As a result the TS type `student: Student` can actually be `undefined` at runtime whenever the relation is not explicitly joined, which can lead to null-pointer errors in consumers (e.g. services doing `mapping.student.xxx`). Align the definitions by declaring `@ManyToOne(() => Student, { nullable: false, onDelete: 'CASCADE' })` or typing the property as `Student | null` and null-checking at call sites.

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

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


Cardinality mismatch: `student_id` is declared `unique: true`, so a student can have at most one Ding mapping — this is strictly a 1:1 relationship, not a many-to-one. Using `@ManyToOne` here is misleading about the data model. Express the intent with `@OneToOne(() => Student, { onDelete: 'CASCADE' })` + `@JoinColumn({ name: 'student_id' })`, which also makes the `unique` constraint on the join column redundant/implicit.


The `student_id` column is declared twice: once via `@Column` and once implicitly through `@ManyToOne`/`@JoinColumn` with the same name. TypeORM 0.3.x reuses the existing column, but this leaves two sources of truth for the same DB column (type, nullability, uniqueness), and any drift between them (e.g. changing nullability on one side) can silently produce inconsistent DDL/runtime behavior. Keep a single source of truth — e.g. keep the `@Column` for `unique`, or drop it and enforce uniqueness via `@Index({ unique: true })` on the relation — and add a short comment explaining that this is an enforced 1:1 mapping.

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


`SyncPlatform` is a shared domain type that is imported from another entity file (`sync-log.entity.ts`) rather than from a shared types module. This couples `SyncState` to `SyncLog` purely for a type: any rename/refactor of the type in the log entity silently breaks this entity, and the union may drift out of sync with the DB column. Recommend extracting `SyncPlatform` (and the other sync union types) into a dedicated shared types file and importing from there.

import type { SyncPlatform } from './sync-log.entity';


The `platform` column is sized `varchar(20)`, but the longest current `SyncPlatform` value (`'dingtalk_attendance'`) is already 19 characters. This leaves no headroom: adding a new platform with a name longer than 20 chars will cause a DB truncation/insert failure, and this length must be manually kept in sync with `migrations/1784520727860-InitialSchema.ts` and `init.sql`. Consider using a larger length (e.g., 32) or a DB-level CHECK constraint to enforce the allowed values.

@PrimaryColumn({ type: 'varchar', length: 20 })
  platform: SyncPlatform;


The `student_id` database column is mapped twice: once by the `@Column` property `studentId` and once by the `@JoinColumn({ name: 'student_id' })` of the `student` relation. TypeORM will register two column metadata entries for the same physical column, so an INSERT (e.g. `profileRepo.create({ ...dto, studentId })` in archive.service) can emit the column twice in the SQL (`INSERT INTO student_profiles (student_id, ..., student_id) ...`), causing MySQL error 1110 "Column 'student_id' specified twice" or ambiguous mapping at runtime. Keep only the relation (with `@JoinColumn`) and expose the FK id via `@RelationId`, or drop the relation and keep only the `@Column`.

  @RelationId((profile: StudentProfile) => profile.student)
  studentId: number;


`eager: true` on this `@ManyToOne` forces a LEFT/INNER JOIN on `students` for every `StudentProfile` query (findOne/find/save), even when only the FK is needed. Given `studentId` is already `unique`, this is effectively a 1:1 relationship — prefer `@OneToOne` without `eager` and load `student` explicitly where it is actually used (e.g. via `relations`/`leftJoinAndSelect`), which avoids the unconditional JOIN overhead and follows the pattern used elsewhere in this codebase (e.g. `Student.user` is not eager).

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

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


`DECIMAL(12,2)` columns are returned as strings by the DB driver (MySQL/pg) and TypeORM performs no automatic conversion to number. Declaring `amount`/`balanceAfter` as `number` therefore misrepresents the runtime type: downstream code doing arithmetic (`tx.amount + x`) would get string concatenation, and `toFixed()`/numeric methods would throw. Type them as `string` or add an explicit transformer, e.g. `transformer: { to: (v) => v, from: (v) => Number(v) }`, so the entity type matches what is actually read back from the database.

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

  @Column({ name: 'balance_after', type: 'decimal', precision: 12, scale: 2 })
  balanceAfter: number;


`operation_id` is limited to varchar(64), but `wallets.service.ts` `batchChangeBalance` writes a compound value `${operationId}:${studentId}` into this column. A user-supplied operationId near the 64-char limit plus the `:` and studentId suffix will exceed 64 characters, causing the INSERT to fail with a data-too-long error (the value is not re-validated before being persisted). Increase the column length (e.g., 128) or avoid embedding the studentId into the stored operationId.

  @Column({ name: 'operation_id', type: 'varchar', length: 64, nullable: true })
  operationId: string | null;


Category values ['room', 'personal', 'both'] are business constants hardcoded and duplicated verbatim in both CreateExpenseTypeDto and UpdateExpenseTypeDto, so they can silently drift out of sync. Extract a shared constant (e.g., `export const EXPENSE_TYPE_CATEGORIES = ['room', 'personal', 'both'] as const;` plus a derived union type) and use `@IsIn(EXPENSE_TYPE_CATEGORIES)` in both DTOs.

  @Matches(/\S/)
  name: string;

  @IsOptional()
  @IsIn(['room', 'personal', 'both'])
  category?: string;


API surface is inconsistent: CreateExpenseTypeDto has no way to set `enabled`, while UpdateExpenseTypeDto does, so a client cannot explicitly create a disabled expense type and must rely on the service's default. If defaulting to enabled on create is intended, document it (or add `enabled` to the create DTO for symmetry).


`onModuleInit` awaits `seedDefaults()` without any error handling. Any failure during seeding (e.g., the database is temporarily unavailable, a migration hasn't run yet, or a row violates a constraint) throws and aborts the entire application bootstrap — the HTTP server will never start. Consider wrapping the call in try/catch with error logging, or moving seeding to `onApplicationBootstrap` so a seeding failure doesn't crash startup (or at least document that a fail-fast boot is intentional).

  async onModuleInit() {
    try {
      await this.service.seedDefaults();
    } catch (err) {
      this.logger.error('Failed to seed default expense types', err);
    }
  }

  async onModuleInit() {
    await this.service.seedDefaults();
  }


The operation log is awaited without error handling after a successful DB write. If `logService.log` throws (e.g., DB insert failure), the whole request returns 500 even though the expense type was already created — the client may then retry and hit a duplicate-code conflict or create duplicates. Operation logging should be best-effort: wrap in try/catch (and log the failure) or don't block the response. The same pattern exists in `update` and `remove`.


The `extractRequestInfo` + `logService.log` block is duplicated across `create`, `update`, and `remove`. Extract a private helper (e.g., `logOperation(req, action, targetId, detail)`) to reduce duplication and make the logging behavior (including error handling) consistent.

    const { ipAddress, userAgent } = extractRequestInfo(req);
    const result = await this.service.create(dto);


`ParseIntPipe` already converts `id` to a number, so the unary `+` is a redundant no-op. Use `id` directly (same for the `remove` handler).

const result = await this.service.update(+id, dto);


Same as `update`: `ParseIntPipe` already yields a number, so `+id` is redundant — use `id` directly.


`exam:edit` is listed in DEPRECATED_PERMISSION_CODES (rbac-presets.ts) and is actively removed from every role and deleted from the permission table by rbac-seed.service.ts. As a result, for any non-superadmin user `req.user.permissions.includes('exam:edit')` can never be true, so the "manage all" branch is effectively dead code and users can never obtain global scope through RBAC (e.g., a user holding the active `exam:purge` permission would still be restricted to their own scope when purging). Use an active permission code or rework the scope model.

  private canManageAll(req: AuthenticatedRequest) {
    return req.user.isSuperAdmin || req.user.permissions.includes('exam:view');
  }

  private canManageAll(req: AuthenticatedRequest) {
    return req.user.isSuperAdmin || req.user.permissions.includes('exam:edit');
  }


Mutating endpoints (create, archive, restore, batch-archive, batch-restore, updateScore) are protected only by `@RequirePermission('exam:view')`, a view-level permission. Any user granted read access can create exams, archive/restore them, and overwrite scores — inconsistent with every other module in this codebase where create/edit/delete operations require dedicated `:create`/`:edit`/`:delete` codes. Since `exam:create`/`exam:edit` were deprecated, at minimum introduce a distinct edit permission for these mutations (or enforce an explicit scope/ownership check at the guard level) to preserve least privilege.

  @Post()
  @RequirePermission('exam:view')
  async create(@Body() dto: CreateExamDto, @Request() req: AuthenticatedRequest) {


`dto.score === null || dto.score === undefined` is evaluated three times in this handler. Extract it to a local constant to avoid duplication and keep the action/detail logic consistent.

    const isCleared = dto.score === null || dto.score === undefined;
    await logAudit(this.logService, req, {
      module: '考试管理', action: isCleared ? '清空成绩' : '录入成绩', targetId: scoreId, targetType: 'exam_score', detail: isCleared ? '成绩已清空' : `成绩：${dto.score}`,
    });

module: '考试管理', action: dto.score === null || dto.score === undefined ? '清空成绩' : '录入成绩', targetId: scoreId, targetType: 'exam_score', detail: dto.score === null || dto.score === undefined ? '成绩已清空' : `成绩：${dto.score}`,


Type-safety issue in the custom transform: when `value` doesn't match any recognized input (e.g., an invalid or empty string), the raw value is returned while being cast `as boolean`. It only 'works' because `@IsBoolean()` later rejects the invalid value, but the cast asserts a type contract the code doesn't actually guarantee and can mask future refactoring mistakes. Since `value` is already typed as `any` in `TransformFnParams`, the cast is unnecessary — remove it (or return an explicit boolean) so the transform's contract is honest.

    if (value === 'false' || value === '0') return false;
    return value;

    if (value === 'false' || value === '0') return false;
    return value as boolean;


`999.99` is a business rule (maximum allowed score) hardcoded inline. Per the hardcoding guideline, extract it into a named constant (e.g., `MAX_EXAM_SCORE`) so the rule is documented and reused consistently. Also note that `@Max` is inclusive (`score <= 999.99`); confirm that boundary is the intended one.

@IsOptional() @IsNumber() @Min(0) @Max(999.99) score?: number | null;


`examType` and `classId` are declared with near-identical validation in both `CreateExamDto` and `QueryExamDto` (only `@IsOptional`/`@Type` differ). Consider extracting the shared fields into a base DTO or shared partial so the validation rules can't drift between the create and query paths.

  @IsOptional() @IsString() keyword?: string;
  @IsOptional() @IsString() examType?: string;
  @IsOptional() @Type(() => Number) @IsInt() @Min(1) classId?: number;


`remove()` soft-deletes a type by setting `enabled=false`, but this uniqueness check matches soft-deleted rows as well. As a result, re-creating a previously removed type with the same code always throws ConflictException, and `seedDefaults()` will never restore a disabled default type either. Scope the uniqueness check to enabled records only (and consider re-enabling soft-deleted defaults in `seedDefaults`).

    const normalized = { ...dto, code: dto.code.trim(), name: dto.name.trim() };
    const exists = await this.repo.findOne({ where: { code: normalized.code, enabled: true } });
    if (exists) throw new ConflictException('费用类型代码已存在');

    const normalized = { ...dto, code: dto.code.trim(), name: dto.name.trim() };
    const exists = await this.repo.findOne({ where: { code: normalized.code } });
    if (exists) throw new ConflictException('费用类型代码已存在');


`update` trims `name` but not `code` and performs no uniqueness validation, unlike `create`. A caller can set `code` to a value already used by another type (including a soft-deleted one), leading to duplicate codes and inconsistent data. Trim the code and check for conflicts against enabled records excluding the current id before saving.

  async update(id: number, dto: UpdateExpenseTypeDto): Promise<ExpenseType> {
    const t = await this.findOne(id);
    Object.assign(t, dto, dto.name === undefined ? {} : { name: dto.name.trim() });
    return this.repo.save(t);
  }


The find-then-save operations for each default type are independent, so they can be executed in parallel with `Promise.all` instead of awaiting sequentially. Also note the soft-delete interaction: a default type disabled via `remove()` will never be re-created here because the row still exists — if defaults should be restorable, re-enable existing disabled rows instead of skipping them.

  async seedDefaults(): Promise<void> {
    for (const t of DEFAULT_TYPES) {
      const exists = await this.repo.findOne({ where: { code: t.code } });
      if (!exists) {
        await this.repo.save(this.repo.create(t));
      }
    }
  }


No validation enforces that periodEnd >= periodStart. A client can submit a range where end precedes start (e.g., periodStart='2026-08-01', periodEnd='2026-01-01'), and this DTO will pass it through to the service. Add cross-field validation (e.g., a custom validator or service-level check) for CreateRoomExpenseDto, BatchRoomExpenseDto, and CreateStudentUtilityBillDto. The same applies to QueryRoomExpenseDto where a reversed range can silently return an empty result.

  @IsDateString()
  periodEnd: string;

  @IsOptional()
  @IsString()
  description?: string;
}


These period fields are validated only by a format regex (`^\d{4}-\d{2}-\d{2}$`), which accepts impossible dates such as '2026-99-99' or '2026-02-30'. This is inconsistent with the other DTOs in this file (CreateRoomExpenseDto / CreatePersonalExpenseDto) that also use `@IsISO8601({ strict: true })` to verify the value is a real calendar date. Add the strict ISO8601 check here as well.

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

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


Redundant date validators: `@IsDateString()` is fully subsumed by `@IsISO8601({ strict: true })` (strict ISO8601 is a stricter subset of IsDateString), and `@Matches` already pins the exact YYYY-MM-DD format. Having all three decorators is confusing and gives no extra protection. Keep `@Matches` (to enforce date-only format) plus `@IsISO8601({ strict: true })`, and drop `@IsDateString()`. This pattern appears in CreateRoomExpenseDto, CreatePersonalExpenseDto, and BatchRoomExpenseDto.

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

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


Inconsistent date validation: create/update DTOs require strict date-only strings (`YYYY-MM-DD` via `@Matches` + `@IsISO8601({ strict: true })`), but the query DTOs only use `@IsDateString()`, which also accepts full ISO timestamps (e.g., '2026-01-01T12:00:00.000Z'). If the query parameters are compared against date-only columns, values like these can cause unexpected filtering behavior. Align the query DTO validators with the create DTOs for a consistent contract.

  @IsOptional()
  @IsDateString()
  periodEnd?: string;


Nested ternary expression (prohibited by review rules) combined with a `-1` magic sentinel to fake "no match". This is fragile: if any class ever has id <= 0, or the access-check logic changes, it silently returns wrong results instead of denying access. Also, silently returning [] for a class the teacher cannot access is inconsistent with `assertClassAccess` (which throws 403) used everywhere else. Refactor to if/else and return early:

      if (query.classId) {
        if (!accessibleClassIds.includes(query.classId)) return [];
        where.classId = query.classId;
      } else {
        where.classId = In(accessibleClassIds);
      }

      where.classId = query.classId
        ? accessibleClassIds.includes(query.classId)
          ? query.classId
          : -1
        : In(accessibleClassIds);


Batch purge deletes rows one at a time in a loop and is not wrapped in a transaction. A failure partway through leaves a partially deleted batch (inconsistent state), and each delete is a separate round-trip (N queries). Collect the archived ids and delete them in a single `IN` query; wrap the whole read-check-delete flow in a transaction for atomicity.

      const archivedIds = exams.filter((exam) => exam.status === 'archived').map((exam) => exam.id);
      if (archivedIds.length > 0) {
        await this.examRepo.delete({ id: In(archivedIds) });
      }
      deleted.push(...archivedIds);

      await this.examRepo.delete(exam.id);
      deleted.push(exam.id);