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 = { 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(); 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; }