329 lines
9.4 KiB
TypeScript
329 lines
9.4 KiB
TypeScript
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<AiReviewSectionType>([
|
|
'students',
|
|
'rooms',
|
|
'transfers',
|
|
'checkins',
|
|
]);
|
|
export const SECTION_ORDER: AiReviewSectionType[] = ['students', 'rooms', 'transfers', 'checkins'];
|
|
export const SECTION_DEPENDENCIES: Record<AiReviewSectionType, AiReviewSectionType[]> = {
|
|
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<AiReviewSectionType, Record<string, string>> = {
|
|
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<AiReviewSectionType, Record<string, string>> = {
|
|
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<AiReviewSectionType, Set<string>> = {
|
|
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<string, unknown> {
|
|
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<string, unknown>, allowed: Set<string>, 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,
|
|
};
|
|
}
|