import { BadRequestException } from '@nestjs/common'; import type { AiReview, AiReviewSection, AiReviewSectionType, } from './entities/ai-review.entity'; export const MAX_TITLE = 50; export const MAX_SUMMARY = 500; export const MAX_SECTIONS = 20; export const MAX_SECTION_TITLE = 50; export const MAX_COLUMNS = 30; export const MAX_COLUMN_KEY = 50; export const MAX_COLUMN_TITLE = 50; export const MAX_ROWS = 500; export const MAX_CELL_LENGTH = 200; export const MAX_ISSUES = 50; export const MAX_ISSUE_LENGTH = 200; export const MAX_SECTIONS_JSON_BYTES = 12 * 1024 * 1024; export const MAX_SECTION_JSON_BYTES = Math.floor(MAX_SECTIONS_JSON_BYTES / MAX_SECTIONS); export const MAX_CAPACITY = 200; export const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; export const COLUMN_KEY_RE = /^[a-zA-Z0-9_]{1,50}$/; export const SECTION_KEY_RE = /^[a-zA-Z0-9_]{1,50}$/; export const SECTION_TYPES = new Set([ 'students', 'rooms', 'transfers', 'checkins', ]); export const SECTION_ORDER: AiReviewSectionType[] = ['students', 'rooms', 'transfers', 'checkins']; export const SECTION_DEPENDENCIES: Record = { students: [], rooms: [], transfers: ['students', 'rooms'], checkins: [], }; export const SCHEMA_KEYS = new Set(['title', 'summary', 'sections']); export const SECTION_KEYS_ALLOWED = new Set([ 'key', 'type', 'title', 'kind', 'sheet', 'columns', 'rows', 'issues', ]); export const COLUMN_KEYS_ALLOWED = new Set(['key', 'title']); /** * Column-key aliases the model may produce when parsing workbooks. * Keys are normalized to canonical names per section so the import * logic only deals with one vocabulary. */ export const SECTION_ALIASES: Record> = { students: { org: 'organization', organizationName: 'organization', orgName: 'organization', }, rooms: { roomNo: 'roomNumber', number: 'roomNumber', }, transfers: { fromRoom: 'oldRoom', currentRoom: 'oldRoom', sourceRoom: 'oldRoom', toRoom: 'newRoom', targetRoom: 'newRoom', destRoom: 'newRoom', date: 'transferDate', changeDate: 'transferDate', moveDate: 'transferDate', mobile: 'studentPhone', phone: 'studentPhone', }, checkins: { studentName: 'name', mobile: 'phone', roomNo: 'roomNumber', room: 'roomNumber', date: 'checkInDate', inDate: 'checkInDate', checkinDate: 'checkInDate', outDate: 'checkOutDate', checkoutDate: 'checkOutDate', }, }; /** * Excel 表头 → 规范列名。与 SECTION_ALIASES 合并使用; * 键会被归一化(去空格/下划线/大小写),因此同时覆盖中文与英文写法。 */ export const SECTION_HEADER_ALIASES: Record> = { students: { 姓名: 'name', 学生姓名: 'name', name: 'name', 手机号: 'phone', 电话: 'phone', 联系电话: 'phone', phone: 'phone', mobile: 'phone', 学号: 'studentNo', 学生编号: 'studentNo', studentNo: 'studentNo', studentno: 'studentNo', 性别: 'gender', gender: 'gender', 机构: 'organization', 所属机构: 'organization', 校区: 'organization', 组织: 'organization', organization: 'organization', }, rooms: { 房间号: 'roomNumber', 宿舍号: 'roomNumber', 房号: 'roomNumber', roomNumber: 'roomNumber', roomnumber: 'roomNumber', 容量: 'capacity', 床位数: 'capacity', 床位: 'capacity', capacity: 'capacity', 楼栋: 'building', 楼号: 'building', building: 'building', 楼层: 'floor', floor: 'floor', 房型: 'roomType', 房间类型: 'roomType', roomType: 'roomType', }, transfers: { 学号: 'studentNo', studentNo: 'studentNo', studentno: 'studentNo', 手机号: 'studentPhone', 学生手机号: 'studentPhone', 电话: 'studentPhone', phone: 'studentPhone', studentPhone: 'studentPhone', 原宿舍: 'oldRoom', 原房间: 'oldRoom', oldRoom: 'oldRoom', 目标宿舍: 'newRoom', 新宿舍: 'newRoom', newRoom: 'newRoom', 换宿日期: 'transferDate', 日期: 'transferDate', transferDate: 'transferDate', }, checkins: { 姓名: 'name', 学生姓名: 'name', name: 'name', 手机号: 'phone', 电话: 'phone', phone: 'phone', mobile: 'phone', 学号: 'studentNo', studentNo: 'studentNo', 宿舍号: 'roomNumber', 房间号: 'roomNumber', roomNumber: 'roomNumber', 楼栋: 'building', building: 'building', 性别: 'gender', gender: 'gender', 入住时间: 'checkInDate', 入住日期: 'checkInDate', checkInDate: 'checkInDate', 计费起始日: 'billingStartDate', 计费开始日: 'billingStartDate', 退宿日期: 'checkOutDate', 退宿时间: 'checkOutDate', 离宿时间: 'checkOutDate', 入住类型: 'stayType', 住宿类型: 'stayType', }, }; export const SECTION_CANONICAL_KEYS: Record> = { students: new Set(['name', 'phone', 'studentNo', 'gender', 'organization']), rooms: new Set(['roomNumber', 'capacity', 'building', 'floor', 'roomType']), transfers: new Set(['studentNo', 'studentPhone', 'oldRoom', 'newRoom', 'transferDate']), checkins: new Set([ 'name', 'phone', 'studentNo', 'roomNumber', 'checkInDate', 'billingStartDate', 'checkOutDate', 'gender', 'building', 'stayType', ]), }; export interface AiReviewSubmitResult { students: { created: number; skipped: number; issues: string[] }; rooms: { created: number; skipped: number; issues: string[] }; transfers: { completed: number; skipped: number; issues: string[] }; checkins: { completed: number; skipped: number; issues: string[] }; message: string; } export type AiReviewSectionResult = | { created: number; skipped: number; issues: string[] } | { completed: number; skipped: number; issues: string[] }; export interface ValidatedReviewSchema { title: string; summary: string | null; sections: AiReviewSection[]; } export interface AiReviewStepSubmitResult { review: AiReview; result: AiReviewSectionResult; message: string; } export function isPlainRecord(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } export function requireString( value: unknown, label: string, max: number, optional = false, ): string { if (value === undefined || value === null) { if (optional) return ''; throw new BadRequestException(`${label}不能为空`); } if (typeof value !== 'string' || !value.trim()) { throw new BadRequestException(`${label}必须是字符串`); } const trimmed = value.trim(); if (trimmed.length > max) { throw new BadRequestException(`${label}长度不能超过 ${max}`); } return trimmed; } export function assertKeys(raw: Record, allowed: Set, label: string): void { for (const key of Object.keys(raw)) { if (!allowed.has(key)) throw new BadRequestException(`${label}包含未知属性: ${key}`); } } export function toDateString(value: unknown): string | null { if (typeof value === 'string' && DATE_RE.test(value.trim())) return value.trim(); return null; } export function normalizePhone(value: unknown): string | null { if (typeof value !== 'string') return null; const phone = value.replace(/[\s-]/g, ''); return /^1[3-9]\d{9}$/.test(phone) ? phone : null; } export function isSectionType(value: unknown): value is AiReviewSectionType { return typeof value === 'string' && SECTION_TYPES.has(value as AiReviewSectionType); } export function normalizeSectionType( key: string, rawType: unknown, ): AiReviewSectionType { if (rawType !== undefined && rawType !== null && !isSectionType(rawType)) { throw new BadRequestException(`分表业务类型不支持: ${JSON.stringify(rawType)}`); } if (isSectionType(rawType)) return rawType; if (isSectionType(key)) return key; const prefix = SECTION_ORDER.find((type) => key.startsWith(`${type}_`)); if (prefix) return prefix; throw new BadRequestException(`分表标识无法解析业务类型: ${key}`); } export function sectionStatus(section: AiReviewSection): AiReviewSection['status'] { if ( section.status === 'submitted' || section.status === 'failed' || section.status === 'skipped' ) { return section.status; } return 'pending'; } export function emptySectionResult(key: AiReviewSectionType): AiReviewSectionResult { return key === 'transfers' || key === 'checkins' ? { completed: 0, skipped: 0, issues: [] } : { created: 0, skipped: 0, issues: [] }; } export function sectionResultMessage( key: AiReviewSectionType, result: AiReviewSectionResult, ): string { if (key === 'students') { return `成功导入学生 ${(result as { created: number }).created} 人,跳过 ${result.skipped} 条`; } if (key === 'rooms') { return `成功导入宿舍 ${(result as { created: number }).created} 间,跳过 ${result.skipped} 条`; } if (key === 'transfers') { return `成功换宿 ${(result as { completed: number }).completed} 条,跳过 ${result.skipped} 条`; } return `成功入住 ${(result as { completed: number }).completed} 条,跳过 ${result.skipped} 条`; } export function withInitialSectionState(section: AiReviewSection): AiReviewSection { return { ...section, status: 'pending', resultSummary: null, submittedAt: null, }; }