import { BadRequestException, ForbiddenException } from '@nestjs/common';
import * as ExcelJS from 'exceljs';
import { Organization } from '../entities/organization.entity';
import { Room } from '../entities/room.entity';
import { Student } from '../entities/student.entity';
import { Occupancy } from '../entities/occupancy.entity';
import { ImportRun } from './entities/import-run.entity';
import { ImportStep } from './entities/import-step.entity';
import { ImportRow } from './entities/import-row.entity';
import { ImportsService } from './imports.service';
import type { ParsedImportFile } from './imports.types';

function makeRowsRepo() {
  let nextId = 1;
  return {
    create: jest.fn((value: unknown) => value),
    save: jest.fn(async (rows: unknown[]) => {
      const list = Array.isArray(rows) ? rows : [rows];
      for (const row of list) {
        const record = row as { id?: number };
        if (record.id === undefined) record.id = nextId++;
      }
      return list;
    }),
    delete: jest.fn().mockResolvedValue({ affected: 0 }),
    find: jest.fn().mockResolvedValue([]),
  };
}

function makeStepsRepo(step: ImportStep) {
  return {
    create: jest.fn((value: unknown) => value),
    save: jest.fn(async (value: unknown) => value),
    findOne: jest.fn().mockResolvedValue(step),
    find: jest.fn().mockResolvedValue([]),
  };
}

function makeRunsRepo(run: ImportRun) {
  return {
    create: jest.fn((value: unknown) => value),
    save: jest.fn(async (value: unknown) => value),
    findOne: jest.fn().mockResolvedValue(run),
  };
}

function studentSheet() {
  return {
    name: '学生',
    headers: ['姓名', '学号', '手机号'],
    rows: [['张三', '2024001', '13800138000']],
  };
}

async function xlsxBuffer(sheet: {
  name: string;
  headers: string[];
  rows: unknown[][];
}): Promise<Buffer> {
  const workbook = new ExcelJS.Workbook();
  const ws = workbook.addWorksheet(sheet.name);
  ws.addRow(sheet.headers);
  for (const row of sheet.rows) ws.addRow(row);
  return (await workbook.xlsx.writeBuffer()) as Buffer;
}

function fileOf(name: string, buffer: Buffer): ParsedImportFile {
  return {
    originalName: name,
    mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
    size: buffer.length,
    buffer,
  };
}

const principal = {
  id: 7,
  permissions: ['student:import', 'room:create', 'occupancy:checkin'],
  isSuperAdmin: false,
};

describe('ImportsService', () => {
  it('拒绝 .xls 文件', async () => {
    const service = new ImportsService(
      makeRunsRepo({} as ImportRun) as never,
      makeStepsRepo({} as ImportStep) as never,
      makeRowsRepo() as never,
      {} as never,
    );
    await expect(
      service.createRun(principal, 'manual', fileOf('a.xls', Buffer.from('not excel'))),
    ).rejects.toThrow('暂不支持 .xls');
  });

  it('上传学生表时自动分配 students 阶段并返回运行详情', async () => {
    const buffer = await xlsxBuffer(studentSheet());
    const run = {
      id: 'run-1',
      userId: 7,
      conversationId: null,
      source: 'manual',
      fileName: 'students.xlsx',
      sheetsJson: JSON.stringify([studentSheet()]),
      status: 'ready',
      currentStepKey: 'students',
      error: null,
      createdAt: new Date(),
      updatedAt: new Date(),
    } as ImportRun;
    const steps = [
      {
        id: 1,
        runId: 'run-1',
        stepKey: 'students',
        sheetsJson: '["学生"]',
        mappingJson: '{"name":"姓名","studentNo":"学号","phone":"手机号"}',
        status: 'pending',
      },
      {
        id: 2,
        runId: 'run-1',
        stepKey: 'rooms',
        sheetsJson: '[]',
        mappingJson: null,
        status: 'skipped',
      },
      {
        id: 3,
        runId: 'run-1',
        stepKey: 'checkins',
        sheetsJson: '[]',
        mappingJson: null,
        status: 'skipped',
      },
      {
        id: 4,
        runId: 'run-1',
        stepKey: 'transfers',
        sheetsJson: '[]',
        mappingJson: null,
        status: 'skipped',
      },
    ] as unknown as ImportStep[];
    const runsRepo = makeRunsRepo(run);
    const stepsRepo = {
      create: jest.fn((value: unknown) => value),
      save: jest.fn(async (value: unknown) => value),
      findOne: jest.fn().mockResolvedValue(null),
      find: jest.fn().mockResolvedValue(steps),
    };
    const service = new ImportsService(
      runsRepo as never,
      stepsRepo as never,
      makeRowsRepo() as never,
      {} as never,
    );

    const detail = await service.createRun(principal, 'manual', fileOf('students.xlsx', buffer));
    expect(detail.currentStepKey).toBe('students');
    expect(detail.steps.find((step) => step.stepKey === 'students')?.sheets).toEqual(['学生']);
    expect(detail.steps.find((step) => step.stepKey === 'students')?.mapping).toEqual({
      name: '姓名',
      studentNo: '学号',
      phone: '手机号',
    });
  });

  it('预览学生阶段：已有学号判为更新，并保留目标记录 ID', async () => {
    const existing = {
      id: 88,
      name: '张三',
      studentNo: '2024001',
      phone: '13800138000',
    } as Student;
    const run = {
      id: 'run-1',
      userId: 7,
      source: 'manual',
      fileName: 'students.xlsx',
      sheetsJson: JSON.stringify([studentSheet()]),
      status: 'ready',
      currentStepKey: 'students',
      error: null,
      createdAt: new Date(),
      updatedAt: new Date(),
    } as ImportRun;
    const step = {
      id: 1,
      runId: 'run-1',
      stepKey: 'students',
      sheetsJson: '["学生"]',
      mappingJson: null,
      status: 'pending',
    } as ImportStep;
    const rowsRepo = makeRowsRepo();
    const dataSource = {
      getRepository: jest.fn((entity: unknown) => {
        if (entity === Student) return { find: jest.fn().mockResolvedValue([existing]) };
        if (entity === Room) return { find: jest.fn().mockResolvedValue([]) };
        if (entity === Organization) return { find: jest.fn().mockResolvedValue([]) };
        if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) };
        return { find: jest.fn().mockResolvedValue([]) };
      }),
    };
    const service = new ImportsService(
      makeRunsRepo(run) as never,
      makeStepsRepo(step) as never,
      rowsRepo as never,
      dataSource as never,
    );

    const result = await service.previewStep(principal, 'run-1', 'students', {
      sheets: ['学生'],
      mapping: { name: '姓名', studentNo: '学号', phone: '手机号' },
    });
    expect(result.summary).toMatchObject({ total: 1, valid: 1, create: 0, update: 1 });
    expect(result.rows[0].action).toBe('update');
    expect(result.rows[0].status).toBe('valid');
    expect(result.rows[0].id).toBeDefined();
  });

  it('预览入住阶段：宿舍不存在时按依赖错误提示', async () => {
    const existing = {
      id: 88,
      name: '张三',
      studentNo: '2024001',
      phone: '13800138000',
    } as Student;
    const run = {
      id: 'run-2',
      userId: 7,
      source: 'manual',
      fileName: 'checkins.xlsx',
      sheetsJson: JSON.stringify([
        {
          name: '入住',
          headers: ['姓名', '手机号', '宿舍号', '入住日期'],
          rows: [['张三', '13800138000', 'A101', '2026-09-01']],
        },
      ]),
      status: 'ready',
      currentStepKey: 'checkins',
      error: null,
      createdAt: new Date(),
      updatedAt: new Date(),
    } as ImportRun;
    const step = {
      id: 3,
      runId: 'run-2',
      stepKey: 'checkins',
      sheetsJson: '["入住"]',
      mappingJson: null,
      status: 'pending',
    } as ImportStep;
    const rowsRepo = makeRowsRepo();
    const dataSource = {
      getRepository: jest.fn((entity: unknown) => {
        if (entity === Student) return { find: jest.fn().mockResolvedValue([existing]) };
        if (entity === Room) return { find: jest.fn().mockResolvedValue([]) };
        if (entity === Organization) return { find: jest.fn().mockResolvedValue([]) };
        if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) };
        return { find: jest.fn().mockResolvedValue([]) };
      }),
    };
    const service = new ImportsService(
      makeRunsRepo(run) as never,
      makeStepsRepo(step) as never,
      rowsRepo as never,
      dataSource as never,
    );

    const result = await service.previewStep(principal, 'run-2', 'checkins', {
      sheets: ['入住'],
      mapping: {
        name: '姓名',
        phone: '手机号',
        roomNumber: '宿舍号',
        checkInDate: '入住日期',
      },
    });
    expect(result.summary).toMatchObject({ total: 1, valid: 0, error: 1 });
    expect(result.rows[0].status).toBe('error');
    expect(result.rows[0].errors.join('；')).toContain('未找到宿舍');
  });

  it('提交阶段需要对应权限；已完成的任务幂等返回回执', async () => {
    const run = {
      id: 'run-1',
      userId: 7,
      source: 'manual',
      fileName: 'students.xlsx',
      sheetsJson: '[]',
      status: 'ready',
      currentStepKey: 'transfers',
      error: null,
      createdAt: new Date(),
      updatedAt: new Date(),
    } as ImportRun;
    const step = {
      id: 4,
      runId: 'run-1',
      stepKey: 'transfers',
      sheetsJson: '["换宿"]',
      mappingJson: '{}',
      status: 'ready',
    } as ImportStep;
    const rowsRepo = makeRowsRepo();
    rowsRepo.find.mockResolvedValue([]);
    const service = new ImportsService(
      makeRunsRepo(run) as never,
      makeStepsRepo(step) as never,
      rowsRepo as never,
      {} as never,
    );
    await expect(
      service.commitStep(
        { id: 7, permissions: ['student:import'], isSuperAdmin: false },
        'run-1',
        'transfers',
        [],
      ),
    ).rejects.toBeInstanceOf(ForbiddenException);

    const committedRun = { ...run, status: 'committed', currentStepKey: null } as ImportRun;
    const committedStep = {
      id: 1,
      runId: 'run-1',
      stepKey: 'students',
      sheetsJson: '["学生"]',
      mappingJson: '{}',
      status: 'committed',
      summaryJson: JSON.stringify({ total: 1, valid: 1, error: 0, create: 1, update: 0, skip: 0 }),
    } as ImportStep;
    const committedService = new ImportsService(
      makeRunsRepo(committedRun) as never,
      makeStepsRepo(committedStep) as never,
      makeRowsRepo() as never,
      {} as never,
    );
    const receipt = await committedService.commitStep(principal, 'run-1', 'students', []);
    expect(receipt.status).toBe('already_committed');
    expect(receipt.created).toBe(1);
  });

  it('提交阶段拒绝与预览分类矛盾的决策', async () => {
    const run = {
      id: 'run-1',
      userId: 7,
      source: 'manual',
      fileName: 'students.xlsx',
      sheetsJson: '[]',
      status: 'ready',
      currentStepKey: 'students',
      error: null,
      createdAt: new Date(),
      updatedAt: new Date(),
    } as ImportRun;
    const step = {
      id: 1,
      runId: 'run-1',
      stepKey: 'students',
      sheetsJson: '["学生"]',
      mappingJson: '{}',
      status: 'ready',
    } as ImportStep;
    const row = {
      id: 11,
      runId: 'run-1',
      stepId: 1,
      sheetName: '学生',
      rowNumber: 3,
      rawJson: '{}',
      normalizedJson: JSON.stringify({ name: '张三', studentNo: '2024001', phone: '13800138000' }),
      matchKey: '2024001',
      action: 'update',
      status: 'valid',
      errorsJson: null,
      targetId: 88,
    } as ImportRow;
    const rowsRepo = makeRowsRepo();
    rowsRepo.find.mockResolvedValue([row]);
    const dataSource = {
      transaction: jest.fn(),
    };
    const service = new ImportsService(
      makeRunsRepo(run) as never,
      makeStepsRepo(step) as never,
      rowsRepo as never,
      dataSource as never,
    );

    await expect(
      service.commitStep(principal, 'run-1', 'students', [{ rowId: 11, action: 'create' }]),
    ).rejects.toBeInstanceOf(BadRequestException);
    await expect(
      service.commitStep(principal, 'run-1', 'students', [{ rowId: 11, action: 'create' }]),
    ).rejects.toThrow('预览判定为「更新」');
    expect(dataSource.transaction).not.toHaveBeenCalled();
  });

  it('预览学生阶段：学号未命中时回退到手机号匹配', async () => {
    const existing = {
      id: 88,
      name: '张三',
      studentNo: '2024001',
      phone: '13800138000',
    } as Student;
    const run = {
      id: 'run-1',
      userId: 7,
      source: 'manual',
      fileName: 'students.xlsx',
      sheetsJson: JSON.stringify([
        {
          name: '学生',
          headers: ['姓名', '学号', '手机号'],
          rows: [['张三', '2024999', '13800138000']],
        },
      ]),
      status: 'ready',
      currentStepKey: 'students',
      error: null,
      createdAt: new Date(),
      updatedAt: new Date(),
    } as ImportRun;
    const step = {
      id: 1,
      runId: 'run-1',
      stepKey: 'students',
      sheetsJson: '["学生"]',
      mappingJson: null,
      status: 'pending',
    } as ImportStep;
    const rowsRepo = makeRowsRepo();
    const dataSource = {
      getRepository: jest.fn((entity: unknown) => {
        if (entity === Student) return { find: jest.fn().mockResolvedValue([existing]) };
        if (entity === Room) return { find: jest.fn().mockResolvedValue([]) };
        if (entity === Organization) return { find: jest.fn().mockResolvedValue([]) };
        if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) };
        return { find: jest.fn().mockResolvedValue([]) };
      }),
    };
    const service = new ImportsService(
      makeRunsRepo(run) as never,
      makeStepsRepo(step) as never,
      rowsRepo as never,
      dataSource as never,
    );

    const result = await service.previewStep(principal, 'run-1', 'students', {
      sheets: ['学生'],
      mapping: { name: '姓名', studentNo: '学号', phone: '手机号' },
    });
    expect(result.summary).toMatchObject({ total: 1, valid: 1, create: 0, update: 1 });
    expect(result.rows[0].action).toBe('update');
    expect(result.rows[0].status).toBe('valid');
    expect(result.rows[0].id).toBeDefined();
  });

  it('预览入住阶段：同一文件内重复入住标记为错误', async () => {
    const existing = {
      id: 88,
      name: '张三',
      studentNo: '2024001',
      phone: '13800138000',
    } as Student;
    const room = { id: 5, roomNumber: 'A101' } as Room;
    const run = {
      id: 'run-2',
      userId: 7,
      source: 'manual',
      fileName: 'checkins.xlsx',
      sheetsJson: JSON.stringify([
        {
          name: '入住',
          headers: ['姓名', '手机号', '宿舍号', '入住日期'],
          rows: [
            ['张三', '13800138000', 'A101', '2026-09-01'],
            ['张三', '13800138000', 'A101', '2026-09-02'],
          ],
        },
      ]),
      status: 'ready',
      currentStepKey: 'checkins',
      error: null,
      createdAt: new Date(),
      updatedAt: new Date(),
    } as ImportRun;
    const step = {
      id: 3,
      runId: 'run-2',
      stepKey: 'checkins',
      sheetsJson: '["入住"]',
      mappingJson: null,
      status: 'pending',
    } as ImportStep;
    const rowsRepo = makeRowsRepo();
    const dataSource = {
      getRepository: jest.fn((entity: unknown) => {
        if (entity === Student) return { find: jest.fn().mockResolvedValue([existing]) };
        if (entity === Room) return { find: jest.fn().mockResolvedValue([room]) };
        if (entity === Organization) return { find: jest.fn().mockResolvedValue([]) };
        if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) };
        return { find: jest.fn().mockResolvedValue([]) };
      }),
    };
    const service = new ImportsService(
      makeRunsRepo(run) as never,
      makeStepsRepo(step) as never,
      rowsRepo as never,
      dataSource as never,
    );

    const result = await service.previewStep(principal, 'run-2', 'checkins', {
      sheets: ['入住'],
      mapping: {
        name: '姓名',
        phone: '手机号',
        roomNumber: '宿舍号',
        checkInDate: '入住日期',
      },
    });
    expect(result.summary).toMatchObject({ total: 2, valid: 1, error: 1 });
    expect(result.rows[0].status).toBe('valid');
    expect(result.rows[1].status).toBe('error');
    expect(result.rows[1].errors.join('；')).toContain('请勿重复导入');
  });

  it('预览换宿阶段：同一文件内重复换宿标记为错误', async () => {
    const existing = {
      id: 88,
      name: '张三',
      studentNo: '2024001',
      phone: '13800138000',
    } as Student;
    const oldRoom = { id: 5, roomNumber: 'A101' } as Room;
    const newRoom = { id: 6, roomNumber: 'B202' } as Room;
    const run = {
      id: 'run-3',
      userId: 7,
      source: 'manual',
      fileName: 'transfers.xlsx',
      sheetsJson: JSON.stringify([
        {
          name: '换宿',
          headers: ['姓名', '手机号', '原宿舍', '新宿舍', '换宿日期'],
          rows: [
            ['张三', '13800138000', 'A101', 'B202', '2026-09-10'],
            ['张三', '13800138000', 'A101', 'B202', '2026-09-11'],
          ],
        },
      ]),
      status: 'ready',
      currentStepKey: 'transfers',
      error: null,
      createdAt: new Date(),
      updatedAt: new Date(),
    } as ImportRun;
    const step = {
      id: 4,
      runId: 'run-3',
      stepKey: 'transfers',
      sheetsJson: '["换宿"]',
      mappingJson: null,
      status: 'pending',
    } as ImportStep;
    const rowsRepo = makeRowsRepo();
    const dataSource = {
      getRepository: jest.fn((entity: unknown) => {
        if (entity === Student) return { find: jest.fn().mockResolvedValue([existing]) };
        if (entity === Room) return { find: jest.fn().mockResolvedValue([oldRoom, newRoom]) };
        if (entity === Organization) return { find: jest.fn().mockResolvedValue([]) };
        if (entity === Occupancy) {
          return {
            find: jest
              .fn()
              .mockResolvedValue([{ id: 77, studentId: 88, roomId: 5, status: 'active' }]),
          };
        }
        return { find: jest.fn().mockResolvedValue([]) };
      }),
    };
    const service = new ImportsService(
      makeRunsRepo(run) as never,
      makeStepsRepo(step) as never,
      rowsRepo as never,
      dataSource as never,
    );

    const result = await service.previewStep(principal, 'run-3', 'transfers', {
      sheets: ['换宿'],
      mapping: {
        name: '姓名',
        phone: '手机号',
        oldRoom: '原宿舍',
        newRoom: '新宿舍',
        transferDate: '换宿日期',
      },
    });
    expect(result.summary).toMatchObject({ total: 2, valid: 1, error: 1 });
    expect(result.rows[0].status).toBe('valid');
    expect(result.rows[1].status).toBe('error');
    expect(result.rows[1].errors.join('；')).toContain('请勿重复换宿');
  });

  it('预览学生阶段：updateExisting=false 时已匹配行改为跳过', async () => {
    const existing = {
      id: 88,
      name: '张三',
      studentNo: '2024001',
      phone: '13800138000',
    } as Student;
    const run = {
      id: 'run-1',
      userId: 7,
      source: 'ai',
      fileName: 'students.xlsx',
      sheetsJson: JSON.stringify([studentSheet()]),
      settingsJson: JSON.stringify({ updateExisting: false }),
      status: 'ready',
      currentStepKey: 'students',
      error: null,
      createdAt: new Date(),
      updatedAt: new Date(),
    } as ImportRun;
    const step = {
      id: 1,
      runId: 'run-1',
      stepKey: 'students',
      sheetsJson: '["学生"]',
      mappingJson: null,
      status: 'pending',
    } as ImportStep;
    const rowsRepo = makeRowsRepo();
    const dataSource = {
      getRepository: jest.fn((entity: unknown) => {
        if (entity === Student) return { find: jest.fn().mockResolvedValue([existing]) };
        if (entity === Room) return { find: jest.fn().mockResolvedValue([]) };
        if (entity === Organization) return { find: jest.fn().mockResolvedValue([]) };
        if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) };
        return { find: jest.fn().mockResolvedValue([]) };
      }),
    };
    const service = new ImportsService(
      makeRunsRepo(run) as never,
      makeStepsRepo(step) as never,
      rowsRepo as never,
      dataSource as never,
    );

    const result = await service.previewStep(principal, 'run-1', 'students', {
      sheets: ['学生'],
      mapping: { name: '姓名', studentNo: '学号', phone: '手机号' },
    });
    expect(result.summary).toMatchObject({
      total: 1,
      valid: 1,
      create: 0,
      update: 0,
      skip: 1,
      error: 0,
    });
    expect(result.rows[0]).toMatchObject({ action: 'skip', status: 'valid' });
    expect(result.rows[0].errors.join('；')).toContain('按策略跳过更新');
  });

  it('预览入住阶段：duplicatePolicy=skip 时文件内重复行跳过并保留提示', async () => {
    const existing = {
      id: 88,
      name: '张三',
      studentNo: '2024001',
      phone: '13800138000',
    } as Student;
    const room = { id: 5, roomNumber: 'A101' } as Room;
    const run = {
      id: 'run-2',
      userId: 7,
      source: 'ai',
      fileName: 'checkins.xlsx',
      sheetsJson: JSON.stringify([
        {
          name: '入住',
          headers: ['姓名', '手机号', '宿舍号', '入住日期'],
          rows: [
            ['张三', '13800138000', 'A101', '2026-09-01'],
            ['张三', '13800138000', 'A101', '2026-09-02'],
          ],
        },
      ]),
      settingsJson: JSON.stringify({ duplicatePolicy: 'skip' }),
      status: 'ready',
      currentStepKey: 'checkins',
      error: null,
      createdAt: new Date(),
      updatedAt: new Date(),
    } as ImportRun;
    const step = {
      id: 3,
      runId: 'run-2',
      stepKey: 'checkins',
      sheetsJson: '["入住"]',
      mappingJson: null,
      status: 'pending',
    } as ImportStep;
    const rowsRepo = makeRowsRepo();
    const dataSource = {
      getRepository: jest.fn((entity: unknown) => {
        if (entity === Student) return { find: jest.fn().mockResolvedValue([existing]) };
        if (entity === Room) return { find: jest.fn().mockResolvedValue([room]) };
        if (entity === Organization) return { find: jest.fn().mockResolvedValue([]) };
        if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) };
        return { find: jest.fn().mockResolvedValue([]) };
      }),
    };
    const service = new ImportsService(
      makeRunsRepo(run) as never,
      makeStepsRepo(step) as never,
      rowsRepo as never,
      dataSource as never,
    );

    const result = await service.previewStep(principal, 'run-2', 'checkins', {
      sheets: ['入住'],
      mapping: {
        name: '姓名',
        phone: '手机号',
        roomNumber: '宿舍号',
        checkInDate: '入住日期',
      },
    });
    expect(result.summary).toMatchObject({
      total: 2,
      valid: 2,
      error: 0,
      create: 1,
      skip: 1,
    });
    expect(result.rows[1]).toMatchObject({ action: 'skip', status: 'valid' });
    expect(result.rows[1].errors.join('；')).toContain('已按策略跳过');
  });

  it('预览入住阶段：skipUnmatched=true 时找不到宿舍的行跳过并保留提示', async () => {
    const existing = {
      id: 88,
      name: '张三',
      studentNo: '2024001',
      phone: '13800138000',
    } as Student;
    const run = {
      id: 'run-2',
      userId: 7,
      source: 'ai',
      fileName: 'checkins.xlsx',
      sheetsJson: JSON.stringify([
        {
          name: '入住',
          headers: ['姓名', '手机号', '宿舍号', '入住日期'],
          rows: [['张三', '13800138000', 'A101', '2026-09-01']],
        },
      ]),
      settingsJson: JSON.stringify({ skipUnmatched: true }),
      status: 'ready',
      currentStepKey: 'checkins',
      error: null,
      createdAt: new Date(),
      updatedAt: new Date(),
    } as ImportRun;
    const step = {
      id: 3,
      runId: 'run-2',
      stepKey: 'checkins',
      sheetsJson: '["入住"]',
      mappingJson: null,
      status: 'pending',
    } as ImportStep;
    const rowsRepo = makeRowsRepo();
    const dataSource = {
      getRepository: jest.fn((entity: unknown) => {
        if (entity === Student) return { find: jest.fn().mockResolvedValue([existing]) };
        if (entity === Room) return { find: jest.fn().mockResolvedValue([]) };
        if (entity === Organization) return { find: jest.fn().mockResolvedValue([]) };
        if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) };
        return { find: jest.fn().mockResolvedValue([]) };
      }),
    };
    const service = new ImportsService(
      makeRunsRepo(run) as never,
      makeStepsRepo(step) as never,
      rowsRepo as never,
      dataSource as never,
    );

    const result = await service.previewStep(principal, 'run-2', 'checkins', {
      sheets: ['入住'],
      mapping: {
        name: '姓名',
        phone: '手机号',
        roomNumber: '宿舍号',
        checkInDate: '入住日期',
      },
    });
    expect(result.summary).toMatchObject({
      total: 1,
      valid: 1,
      error: 0,
      create: 0,
      skip: 1,
    });
    expect(result.rows[0]).toMatchObject({ action: 'skip', status: 'valid' });
    expect(result.rows[0].errors.join('；')).toContain('按策略跳过');
  });
});
