225 lines
8.5 KiB
TypeScript
225 lines
8.5 KiB
TypeScript
import * as ExcelJS from 'exceljs';
|
|
|
|
export interface OccupancyImportRow {
|
|
roomNumber: string;
|
|
building?: string;
|
|
bedNumber?: string;
|
|
lockerNumber?: string;
|
|
name: string;
|
|
gender?: string;
|
|
ethnicity?: string;
|
|
phone?: string;
|
|
idNumber?: string;
|
|
checkInDate: string;
|
|
billingStartDate?: string;
|
|
checkOutDate?: string;
|
|
stayType?: string;
|
|
emergencyContact?: string;
|
|
emergencyPhone?: string;
|
|
organization?: string;
|
|
supervisor?: string;
|
|
notes?: string;
|
|
}
|
|
|
|
export const OCCUPANCY_IMPORT_COLUMNS = [
|
|
{ header: '宿舍号', key: 'roomNumber', width: 12 },
|
|
{ header: '楼栋', key: 'building', width: 10 },
|
|
{ header: '床位号', key: 'bedNumber', width: 10 },
|
|
{ header: '柜子号', key: 'lockerNumber', width: 10 },
|
|
{ header: '姓名', key: 'name', width: 12 },
|
|
{ header: '性别', key: 'gender', width: 8 },
|
|
{ header: '民族', key: 'ethnicity', width: 10 },
|
|
{ header: '电话', key: 'phone', width: 15 },
|
|
{ header: '学号/身份证', key: 'idNumber', width: 22 },
|
|
{ header: '入住时间', key: 'checkInDate', width: 14 },
|
|
{ header: '计费起始日', key: 'billingStartDate', width: 14 },
|
|
{ header: '离宿时间', key: 'checkOutDate', width: 14 },
|
|
{ header: '入住类型', key: 'stayType', width: 10 },
|
|
{ header: '紧急联系人', key: 'emergencyContact', width: 15 },
|
|
{ header: '紧急联系人电话', key: 'emergencyPhone', width: 18 },
|
|
{ header: '所属机构', key: 'organization', width: 18 },
|
|
{ header: '负责人/班主任', key: 'supervisor', width: 15 },
|
|
{ header: '备注', key: 'notes', width: 20 },
|
|
] as const;
|
|
|
|
const HEADER_ALIASES: Record<keyof OccupancyImportRow, string[]> = {
|
|
roomNumber: ['宿舍号', '房间号'],
|
|
building: ['楼栋'],
|
|
bedNumber: ['床位号'],
|
|
lockerNumber: ['柜子号'],
|
|
name: ['姓名', '学生姓名'],
|
|
gender: ['性别'],
|
|
ethnicity: ['民族'],
|
|
phone: ['电话', '手机号'],
|
|
idNumber: ['学号/身份证', '学号', '身份证号'],
|
|
checkInDate: ['入住时间', '入住日期'],
|
|
billingStartDate: ['计费起始日', '计费开始日'],
|
|
checkOutDate: ['离宿时间', '退宿时间', '退宿日期'],
|
|
stayType: ['入住类型', '住宿类型'],
|
|
emergencyContact: ['紧急联系人'],
|
|
emergencyPhone: ['紧急联系人电话', '紧急联系电话'],
|
|
organization: ['所属机构', '机构'],
|
|
supervisor: ['负责人/班主任', '负责人', '班主任'],
|
|
notes: ['备注'],
|
|
};
|
|
|
|
function cellText(cell: ExcelJS.Cell | undefined): string {
|
|
if (!cell?.value) return '';
|
|
if (typeof cell.value === 'object' && 'text' in cell.value) {
|
|
return String(cell.value.text).trim();
|
|
}
|
|
return String(cell.value).trim();
|
|
}
|
|
|
|
function parseDate(cell: ExcelJS.Cell | undefined): string {
|
|
const value = cell?.value;
|
|
if (!value) return '';
|
|
if (value instanceof Date) {
|
|
const year = value.getFullYear();
|
|
const month = String(value.getMonth() + 1).padStart(2, '0');
|
|
const day = String(value.getDate()).padStart(2, '0');
|
|
return `${year}-${month}-${day}`;
|
|
}
|
|
const text = cellText(cell);
|
|
const matched = text.match(/(\d{4})[\/\-.](\d{1,2})[\/\-.](\d{1,2})/);
|
|
if (!matched) return text;
|
|
return `${matched[1]}-${matched[2].padStart(2, '0')}-${matched[3].padStart(2, '0')}`;
|
|
}
|
|
|
|
function normalizeStayType(value: string): string | undefined {
|
|
if (!value) return undefined;
|
|
if (value === '长租' || value.toLowerCase() === 'long') return 'long';
|
|
if (value === '短租' || value.toLowerCase() === 'short') return 'short';
|
|
return value;
|
|
}
|
|
|
|
export function parseOccupancyImportWorksheet(ws: ExcelJS.Worksheet): OccupancyImportRow[] {
|
|
const headerIndexes = new Map<string, number>();
|
|
ws.getRow(1).eachCell((cell, columnNumber) => {
|
|
const header = cellText(cell).replace(/\s+/g, '');
|
|
if (header) headerIndexes.set(header, columnNumber);
|
|
});
|
|
|
|
const columnFor = (key: keyof OccupancyImportRow): number | undefined => {
|
|
for (const alias of HEADER_ALIASES[key]) {
|
|
const index = headerIndexes.get(alias.replace(/\s+/g, ''));
|
|
if (index) return index;
|
|
}
|
|
return undefined;
|
|
};
|
|
const getCell = (row: ExcelJS.Row, key: keyof OccupancyImportRow) => {
|
|
const index = columnFor(key);
|
|
return index ? row.getCell(index) : undefined;
|
|
};
|
|
|
|
const rows: OccupancyImportRow[] = [];
|
|
let lastRoomNumber = '';
|
|
ws.eachRow((row, rowNumber) => {
|
|
if (rowNumber === 1) return;
|
|
const roomNumber = cellText(getCell(row, 'roomNumber'));
|
|
if (roomNumber) lastRoomNumber = roomNumber;
|
|
const name = cellText(getCell(row, 'name'));
|
|
if (!name) return;
|
|
|
|
rows.push({
|
|
roomNumber: lastRoomNumber,
|
|
building: cellText(getCell(row, 'building')) || undefined,
|
|
bedNumber: cellText(getCell(row, 'bedNumber')) || undefined,
|
|
lockerNumber: cellText(getCell(row, 'lockerNumber')) || undefined,
|
|
name,
|
|
gender: cellText(getCell(row, 'gender')) || undefined,
|
|
ethnicity: cellText(getCell(row, 'ethnicity')) || undefined,
|
|
phone: cellText(getCell(row, 'phone')) || undefined,
|
|
idNumber: cellText(getCell(row, 'idNumber')) || undefined,
|
|
checkInDate: parseDate(getCell(row, 'checkInDate')),
|
|
billingStartDate: parseDate(getCell(row, 'billingStartDate')) || undefined,
|
|
checkOutDate: parseDate(getCell(row, 'checkOutDate')) || undefined,
|
|
stayType: normalizeStayType(cellText(getCell(row, 'stayType'))),
|
|
emergencyContact: cellText(getCell(row, 'emergencyContact')) || undefined,
|
|
emergencyPhone: cellText(getCell(row, 'emergencyPhone')) || undefined,
|
|
organization: cellText(getCell(row, 'organization')) || undefined,
|
|
supervisor: cellText(getCell(row, 'supervisor')) || undefined,
|
|
notes: cellText(getCell(row, 'notes')) || undefined,
|
|
});
|
|
});
|
|
return rows;
|
|
}
|
|
|
|
export function createOccupancyImportTemplateWorkbook(): ExcelJS.Workbook {
|
|
const workbook = new ExcelJS.Workbook();
|
|
const ws = workbook.addWorksheet('入住名单导入模板');
|
|
ws.columns = [...OCCUPANCY_IMPORT_COLUMNS];
|
|
ws.getRow(1).font = { bold: true };
|
|
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
|
ws.views = [{ state: 'frozen', ySplit: 1 }];
|
|
ws.autoFilter = { from: 'A1', to: 'R1' };
|
|
|
|
ws.addRow({
|
|
roomNumber: '4-102',
|
|
building: '4号楼',
|
|
bedNumber: '1号床',
|
|
lockerNumber: 'A01',
|
|
name: '张三',
|
|
gender: '男',
|
|
ethnicity: '汉族',
|
|
phone: '13800138000',
|
|
idNumber: '2024001',
|
|
checkInDate: '2026-04-21',
|
|
billingStartDate: '2026-04-21',
|
|
checkOutDate: '',
|
|
stayType: '短租',
|
|
emergencyContact: '张父',
|
|
emergencyPhone: '13900000000',
|
|
organization: '',
|
|
supervisor: '',
|
|
notes: '',
|
|
});
|
|
ws.addRow({
|
|
roomNumber: '4-102',
|
|
building: '4号楼',
|
|
bedNumber: '2号床',
|
|
lockerNumber: 'A02',
|
|
name: '李四',
|
|
gender: '男',
|
|
ethnicity: '汉族',
|
|
phone: '13800138001',
|
|
idNumber: '2024002',
|
|
checkInDate: '2026-04-21',
|
|
billingStartDate: '2026-04-22',
|
|
checkOutDate: '',
|
|
stayType: '长租',
|
|
emergencyContact: '',
|
|
emergencyPhone: '',
|
|
organization: 'XXX教育科技',
|
|
supervisor: '王老师',
|
|
notes: '示例数据,导入前请删除',
|
|
});
|
|
|
|
const stayTypeColumnNumber = ws.getColumn('stayType').number;
|
|
for (let row = 2; row <= 1000; row++) {
|
|
ws.getCell(row, stayTypeColumnNumber).dataValidation = {
|
|
type: 'list',
|
|
allowBlank: true,
|
|
formulae: ['"短租,长租"'],
|
|
};
|
|
}
|
|
|
|
const helpWs = workbook.addWorksheet('使用说明');
|
|
helpWs.getColumn(1).width = 90;
|
|
const instructions = [
|
|
'【入住名单导入说明】',
|
|
'1. 宿舍号、姓名、入住时间为必填项;床位号建议填写,柜子号可选。',
|
|
'2. 填写床位号或柜子号后,系统会在对应宿舍中匹配;不存在时自动创建,已被占用时该行导入失败。',
|
|
'3. 宿舍不存在时会自动创建;宿舍号可智能解析楼栋、楼层和房间类型,楼栋列可用于补充楼栋名称。',
|
|
'4. 同一宿舍号的连续多行可以合并或留空,系统会继承上一行宿舍号。',
|
|
'5. 入住类型可填“短租”或“长租”;计费起始日不填时默认等于入住时间。',
|
|
'6. 已存在的学生按姓名匹配,并自动补充其缺失的基础资料。',
|
|
'7. 已有在住记录的学生会自动跳过,不会重复入住。',
|
|
'8. 填写离宿时间的记录会作为历史入住导入,床位和柜子不会被标记为占用。',
|
|
'9. 模板中的两行示例数据仅用于说明,正式导入前请删除或替换。',
|
|
];
|
|
instructions.forEach((instruction) => helpWs.addRow([instruction]));
|
|
helpWs.getRow(1).font = { bold: true, size: 14 };
|
|
return workbook;
|
|
}
|