feat: improve occupancy import template
This commit is contained in:
@@ -27,6 +27,10 @@ import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import { extractRequestInfo } from '../common/request-utils';
|
||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
import * as ExcelJS from 'exceljs';
|
||||
import {
|
||||
createOccupancyImportTemplateWorkbook,
|
||||
parseOccupancyImportWorksheet,
|
||||
} from './occupancy-import-template';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('occupancies')
|
||||
@@ -240,68 +244,7 @@ export class OccupanciesController {
|
||||
@Get('template')
|
||||
@RequirePermission('occupancy:view')
|
||||
async downloadTemplate(@Res() res: Response) {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const ws = workbook.addWorksheet('入住名单导入模板');
|
||||
ws.columns = [
|
||||
{ header: '宿舍号', key: 'roomNumber', width: 12 },
|
||||
{ header: '床位号', key: 'bedNumber', width: 8 },
|
||||
{ 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: 'checkOutDate', width: 14 },
|
||||
{ header: '紧急联系人', key: 'emergencyContact', width: 15 },
|
||||
{ header: '紧急联系人电话', key: 'emergencyPhone', width: 18 },
|
||||
{ header: '所属机构', key: 'organization', width: 18 },
|
||||
{ header: '负责人/班主任', key: 'supervisor', width: 15 },
|
||||
];
|
||||
ws.getRow(1).font = { bold: true };
|
||||
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
||||
// 添加说明行
|
||||
ws.addRow({
|
||||
roomNumber: '4-102',
|
||||
bedNumber: 1,
|
||||
name: '张三',
|
||||
gender: '男',
|
||||
ethnicity: '汉族',
|
||||
phone: '13800138000',
|
||||
idNumber: '2024001',
|
||||
checkInDate: '2026-04-21',
|
||||
checkOutDate: '',
|
||||
emergencyContact: '张父',
|
||||
emergencyPhone: '13900000000',
|
||||
organization: '',
|
||||
supervisor: '',
|
||||
});
|
||||
ws.addRow({
|
||||
roomNumber: '4-102',
|
||||
bedNumber: 2,
|
||||
name: '李四',
|
||||
gender: '男',
|
||||
ethnicity: '汉族',
|
||||
phone: '13800138001',
|
||||
idNumber: '2024002',
|
||||
checkInDate: '2026-04-21',
|
||||
checkOutDate: '',
|
||||
emergencyContact: '',
|
||||
emergencyPhone: '',
|
||||
organization: 'XXX教育科技',
|
||||
supervisor: '王老师',
|
||||
});
|
||||
// 添加使用说明sheet
|
||||
const helpWs = workbook.addWorksheet('使用说明');
|
||||
helpWs.getColumn(1).width = 60;
|
||||
helpWs.addRow(['【入住名单导入说明】']);
|
||||
helpWs.addRow(['1. 导入入住名单会自动创建不存在的学生和宿舍,无需单独导入学生或宿舍']);
|
||||
helpWs.addRow(['2. 宿舍号会智能解析楼栋、楼层和房间类型(如4-102自动识别为4号楼1层四人间)']);
|
||||
helpWs.addRow(['3. 同一宿舍号的多个学生可合并宿舍号单元格,系统会自动继承上一行的宿舍号']);
|
||||
helpWs.addRow(['4. 已存在的学生(按姓名匹配)会自动补充缺失信息(性别、民族等)']);
|
||||
helpWs.addRow(['5. 已有在住记录的学生会自动跳过,不会重复入住']);
|
||||
helpWs.addRow(['6. 填了离宿时间的记录会直接标记为已退宿(用于导入历史数据)']);
|
||||
helpWs.addRow(['7. 床位号仅做标识参考,不影响入住逻辑']);
|
||||
helpWs.getRow(1).font = { bold: true, size: 14 };
|
||||
const workbook = createOccupancyImportTemplateWorkbook();
|
||||
res.setHeader(
|
||||
'Content-Type',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
@@ -324,47 +267,7 @@ export class OccupanciesController {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(file.buffer as any);
|
||||
const ws = workbook.worksheets[0];
|
||||
const rows: any[] = [];
|
||||
let lastRoomNumber = '';
|
||||
|
||||
ws.eachRow((row, idx) => {
|
||||
if (idx === 1) return; // 跳过表头
|
||||
|
||||
// 宿舍号可能是合并单元格,需要继承上一行
|
||||
const roomNumberVal = row.getCell(1).value;
|
||||
const roomNumber = roomNumberVal ? String(roomNumberVal).trim() : '';
|
||||
if (roomNumber) lastRoomNumber = roomNumber;
|
||||
|
||||
const name = String(row.getCell(3).value || '').trim();
|
||||
if (!name) return; // 无姓名则跳过空行
|
||||
|
||||
// 解析日期
|
||||
const parseDate = (cell: any): string => {
|
||||
const val = cell.value;
|
||||
if (!val) return '';
|
||||
if (val instanceof Date) return val.toISOString().split('T')[0];
|
||||
const s = String(val).trim();
|
||||
// 处理 "YYYY/MM/DD" 或 "YYYY-MM-DD" 或 "YYYY.MM.DD"
|
||||
const m = s.match(/(\d{4})[\/\-\.](\d{1,2})[\/\-\.](\d{1,2})/);
|
||||
if (m) return `${m[1]}-${m[2].padStart(2, '0')}-${m[3].padStart(2, '0')}`;
|
||||
return s;
|
||||
};
|
||||
|
||||
rows.push({
|
||||
name,
|
||||
roomNumber: lastRoomNumber,
|
||||
gender: String(row.getCell(4).value || '').trim() || undefined,
|
||||
ethnicity: String(row.getCell(5).value || '').trim() || undefined,
|
||||
phone: String(row.getCell(6).value || '').trim() || undefined,
|
||||
idNumber: String(row.getCell(7).value || '').trim() || undefined,
|
||||
checkInDate: parseDate(row.getCell(8)),
|
||||
checkOutDate: parseDate(row.getCell(9)) || undefined,
|
||||
emergencyContact: String(row.getCell(10).value || '').trim() || undefined,
|
||||
emergencyPhone: String(row.getCell(11).value || '').trim() || undefined,
|
||||
organization: String(row.getCell(12).value || '').trim() || undefined,
|
||||
supervisor: String(row.getCell(13).value || '').trim() || undefined,
|
||||
});
|
||||
});
|
||||
const rows = parseOccupancyImportWorksheet(ws);
|
||||
const result = await this.service.batchImportCheckIn(rows, {
|
||||
autoDeposit: autoDeposit === 'true',
|
||||
depositAmount: depositAmount ? +depositAmount : undefined,
|
||||
|
||||
@@ -49,3 +49,68 @@ describe('OccupanciesService — responsible organization', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OccupanciesService — import bed capacity', () => {
|
||||
it('rejects creating a new bed when the room already has its capacity in beds', async () => {
|
||||
const occupancyRepo = {
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
create: jest.fn((value) => value),
|
||||
save: jest.fn(),
|
||||
} as any as Repository<Occupancy>;
|
||||
const roomRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({ id: 2, roomNumber: '4-102', capacity: 4 }),
|
||||
create: jest.fn((value) => value),
|
||||
save: jest.fn(),
|
||||
update: jest.fn(),
|
||||
} as any as Repository<Room>;
|
||||
const studentRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({ id: 3, name: '张三', organizationId: 7 }),
|
||||
create: jest.fn((value) => value),
|
||||
save: jest.fn(),
|
||||
update: jest.fn(),
|
||||
} as any as Repository<Student>;
|
||||
const bedRepo = {
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
count: jest.fn().mockResolvedValue(4),
|
||||
create: jest.fn((value) => value),
|
||||
save: jest.fn(),
|
||||
update: jest.fn(),
|
||||
} as any as Repository<Bed>;
|
||||
const organizationRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({ id: 7, name: '本机构', isHost: true }),
|
||||
create: jest.fn((value) => value),
|
||||
save: jest.fn(),
|
||||
} as any as Repository<any>;
|
||||
|
||||
const service = new OccupanciesService(
|
||||
occupancyRepo,
|
||||
roomRepo,
|
||||
studentRepo,
|
||||
{ findOne: jest.fn(), create: jest.fn(), save: jest.fn() } as any as Repository<Deposit>,
|
||||
bedRepo,
|
||||
{ findOne: jest.fn() } as any as Repository<Locker>,
|
||||
organizationRepo,
|
||||
{} as DataSource,
|
||||
);
|
||||
|
||||
const result = await service.batchImportCheckIn([
|
||||
{
|
||||
name: '张三',
|
||||
roomNumber: '4-102',
|
||||
bedNumber: '5号床',
|
||||
checkInDate: '2026-07-14',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
imported: 0,
|
||||
skipped: 1,
|
||||
errors: [expect.stringContaining('不能超过额定人数 4')],
|
||||
}),
|
||||
);
|
||||
expect(bedRepo.save).not.toHaveBeenCalled();
|
||||
expect(occupancyRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -341,7 +341,12 @@ export class OccupanciesService {
|
||||
roomNumber: string;
|
||||
building?: string;
|
||||
checkInDate: string;
|
||||
billingStartDate?: string;
|
||||
checkOutDate?: string;
|
||||
bedNumber?: string;
|
||||
lockerNumber?: string;
|
||||
stayType?: string;
|
||||
notes?: string;
|
||||
}[],
|
||||
options?: { autoDeposit?: boolean; depositAmount?: number },
|
||||
) {
|
||||
@@ -450,14 +455,54 @@ export class OccupanciesService {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 5. 匹配或创建床位、柜子,并校验是否可用
|
||||
const isHistoricalRecord = Boolean(row.checkOutDate?.trim());
|
||||
let bed: Bed | null = null;
|
||||
if (row.bedNumber?.trim()) {
|
||||
const bedNumber = row.bedNumber.trim();
|
||||
bed = await this.bedRepo.findOne({ where: { roomId: room.id, bedNumber } });
|
||||
if (!bed) {
|
||||
const existingBedCount = await this.bedRepo.count({ where: { roomId: room.id } });
|
||||
if (existingBedCount >= room.capacity) {
|
||||
throw new BadRequestException(
|
||||
`宿舍 ${room.roomNumber} 已有 ${existingBedCount} 张床位,不能超过额定人数 ${room.capacity}`,
|
||||
);
|
||||
}
|
||||
bed = await this.bedRepo.save(
|
||||
this.bedRepo.create({ roomId: room.id, bedNumber, status: 'available' }),
|
||||
);
|
||||
}
|
||||
if (!isHistoricalRecord && bed.status !== 'available') {
|
||||
throw new BadRequestException(`床位 ${bedNumber} 已被占用或维修中`);
|
||||
}
|
||||
}
|
||||
|
||||
let locker: Locker | null = null;
|
||||
if (row.lockerNumber?.trim()) {
|
||||
const lockerNumber = row.lockerNumber.trim();
|
||||
locker = await this.lockerRepo.findOne({ where: { roomId: room.id, lockerNumber } });
|
||||
if (!locker) {
|
||||
locker = await this.lockerRepo.save(
|
||||
this.lockerRepo.create({ roomId: room.id, lockerNumber, status: 'available' }),
|
||||
);
|
||||
}
|
||||
if (!isHistoricalRecord && locker.status !== 'available') {
|
||||
throw new BadRequestException(`柜子 ${lockerNumber} 已被占用或维修中`);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 创建入住记录
|
||||
const checkInDate = row.checkInDate?.trim() || new Date().toISOString().split('T')[0];
|
||||
const occData: any = {
|
||||
studentId: student.id,
|
||||
roomId: room.id,
|
||||
checkInDate,
|
||||
billingStartDate: checkInDate,
|
||||
billingStartDate: row.billingStartDate?.trim() || checkInDate,
|
||||
stayType: row.stayType || undefined,
|
||||
responsibleOrganizationId: student.organizationId || organization.id,
|
||||
notes: row.notes || undefined,
|
||||
bedId: bed?.id,
|
||||
lockerId: locker?.id,
|
||||
};
|
||||
// 如果有退宿日期,直接记录
|
||||
if (row.checkOutDate?.trim()) {
|
||||
@@ -466,9 +511,13 @@ export class OccupanciesService {
|
||||
}
|
||||
await this.repo.save(this.repo.create(occData));
|
||||
|
||||
// 8. 更新宿舍状态
|
||||
if (!row.checkOutDate?.trim() && count + 1 >= room.capacity) {
|
||||
await this.roomRepo.update(room.id, { status: 'full' });
|
||||
// 7. 更新床位、柜子和宿舍状态
|
||||
if (!isHistoricalRecord) {
|
||||
if (bed) await this.bedRepo.update(bed.id, { status: 'occupied' });
|
||||
if (locker) await this.lockerRepo.update(locker.id, { status: 'occupied' });
|
||||
if (count + 1 >= room.capacity) {
|
||||
await this.roomRepo.update(room.id, { status: 'full' });
|
||||
}
|
||||
}
|
||||
|
||||
// 9. 自动收取押金(仅对新入住且非历史记录的学生)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
createOccupancyImportTemplateWorkbook,
|
||||
OCCUPANCY_IMPORT_COLUMNS,
|
||||
parseOccupancyImportWorksheet,
|
||||
} from './occupancy-import-template';
|
||||
|
||||
describe('occupancy import template', () => {
|
||||
it('includes the current occupancy fields including bed and locker numbers', () => {
|
||||
const workbook = createOccupancyImportTemplateWorkbook();
|
||||
const ws = workbook.getWorksheet('入住名单导入模板')!;
|
||||
const headers = ws.getRow(1).values as unknown[];
|
||||
|
||||
expect(headers).toEqual(
|
||||
expect.arrayContaining([
|
||||
'宿舍号',
|
||||
'楼栋',
|
||||
'床位号',
|
||||
'柜子号',
|
||||
'计费起始日',
|
||||
'入住类型',
|
||||
'备注',
|
||||
]),
|
||||
);
|
||||
expect(ws.columnCount).toBe(OCCUPANCY_IMPORT_COLUMNS.length);
|
||||
});
|
||||
|
||||
it('keeps Excel Date cells on the same local calendar day', () => {
|
||||
const workbook = createOccupancyImportTemplateWorkbook();
|
||||
const ws = workbook.getWorksheet('入住名单导入模板')!;
|
||||
ws.getCell('J2').value = new Date(2026, 3, 21);
|
||||
ws.getCell('K2').value = new Date(2026, 3, 22);
|
||||
ws.getCell('L2').value = new Date(2026, 3, 30);
|
||||
|
||||
expect(parseOccupancyImportWorksheet(ws)[0]).toMatchObject({
|
||||
checkInDate: '2026-04-21',
|
||||
billingStartDate: '2026-04-22',
|
||||
checkOutDate: '2026-04-30',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses rows by header so new columns do not shift existing fields', () => {
|
||||
const workbook = createOccupancyImportTemplateWorkbook();
|
||||
const ws = workbook.getWorksheet('入住名单导入模板')!;
|
||||
const rows = parseOccupancyImportWorksheet(ws);
|
||||
|
||||
expect(rows[0]).toMatchObject({
|
||||
roomNumber: '4-102',
|
||||
building: '4号楼',
|
||||
bedNumber: '1号床',
|
||||
lockerNumber: 'A01',
|
||||
name: '张三',
|
||||
checkInDate: '2026-04-21',
|
||||
billingStartDate: '2026-04-21',
|
||||
stayType: 'short',
|
||||
});
|
||||
expect(rows[1]).toMatchObject({
|
||||
bedNumber: '2号床',
|
||||
lockerNumber: 'A02',
|
||||
stayType: 'long',
|
||||
});
|
||||
});
|
||||
});
|
||||
224
apps/server/src/occupancies/occupancy-import-template.ts
Normal file
224
apps/server/src/occupancies/occupancy-import-template.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
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;
|
||||
}
|
||||
@@ -42,7 +42,9 @@ describe('preset role permissions', () => {
|
||||
expect(accommodation.groups).toEqual(
|
||||
expect.arrayContaining(['room', 'occupancy', 'expense', 'bill', 'deposit']),
|
||||
);
|
||||
expect(accommodation.extras).toContain('student:basic-view');
|
||||
expect(accommodation.extras).toEqual(
|
||||
expect.arrayContaining(['student:basic-view', 'organization:view']),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps classroom rental operations separate from accommodation operations', () => {
|
||||
|
||||
@@ -180,7 +180,7 @@ export const PRESET_ROLES: Array<{
|
||||
'notification',
|
||||
'profile',
|
||||
],
|
||||
extraPermissions: ['student:basic-view'],
|
||||
extraPermissions: ['student:basic-view', 'organization:view'],
|
||||
legacyNames: ['宿管老师', '宿管', '财务'],
|
||||
legacyCodes: ['dormitory_supervisor', 'dorm_manager', 'finance'],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user