Files
gongxue-base/apps/server/src/ai-chat/ai-review.shared.ts

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,
};
}