feat: improve occupancy import template
This commit is contained in:
@@ -598,7 +598,7 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
placeholder="默认为短租"
|
placeholder="默认为短租"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="responsibleOrganizationId" label="负责机构">
|
<Form.Item name="responsibleOrganizationId" label="所属机构">
|
||||||
<Select
|
<Select
|
||||||
showSearch
|
showSearch
|
||||||
allowClear
|
allowClear
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
|||||||
import { extractRequestInfo } from '../common/request-utils';
|
import { extractRequestInfo } from '../common/request-utils';
|
||||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||||
import * as ExcelJS from 'exceljs';
|
import * as ExcelJS from 'exceljs';
|
||||||
|
import {
|
||||||
|
createOccupancyImportTemplateWorkbook,
|
||||||
|
parseOccupancyImportWorksheet,
|
||||||
|
} from './occupancy-import-template';
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('occupancies')
|
@Controller('occupancies')
|
||||||
@@ -240,68 +244,7 @@ export class OccupanciesController {
|
|||||||
@Get('template')
|
@Get('template')
|
||||||
@RequirePermission('occupancy:view')
|
@RequirePermission('occupancy:view')
|
||||||
async downloadTemplate(@Res() res: Response) {
|
async downloadTemplate(@Res() res: Response) {
|
||||||
const workbook = new ExcelJS.Workbook();
|
const workbook = createOccupancyImportTemplateWorkbook();
|
||||||
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 };
|
|
||||||
res.setHeader(
|
res.setHeader(
|
||||||
'Content-Type',
|
'Content-Type',
|
||||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
@@ -324,47 +267,7 @@ export class OccupanciesController {
|
|||||||
const workbook = new ExcelJS.Workbook();
|
const workbook = new ExcelJS.Workbook();
|
||||||
await workbook.xlsx.load(file.buffer as any);
|
await workbook.xlsx.load(file.buffer as any);
|
||||||
const ws = workbook.worksheets[0];
|
const ws = workbook.worksheets[0];
|
||||||
const rows: any[] = [];
|
const rows = parseOccupancyImportWorksheet(ws);
|
||||||
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 result = await this.service.batchImportCheckIn(rows, {
|
const result = await this.service.batchImportCheckIn(rows, {
|
||||||
autoDeposit: autoDeposit === 'true',
|
autoDeposit: autoDeposit === 'true',
|
||||||
depositAmount: depositAmount ? +depositAmount : undefined,
|
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;
|
roomNumber: string;
|
||||||
building?: string;
|
building?: string;
|
||||||
checkInDate: string;
|
checkInDate: string;
|
||||||
|
billingStartDate?: string;
|
||||||
checkOutDate?: string;
|
checkOutDate?: string;
|
||||||
|
bedNumber?: string;
|
||||||
|
lockerNumber?: string;
|
||||||
|
stayType?: string;
|
||||||
|
notes?: string;
|
||||||
}[],
|
}[],
|
||||||
options?: { autoDeposit?: boolean; depositAmount?: number },
|
options?: { autoDeposit?: boolean; depositAmount?: number },
|
||||||
) {
|
) {
|
||||||
@@ -450,14 +455,54 @@ export class OccupanciesService {
|
|||||||
continue;
|
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. 创建入住记录
|
// 6. 创建入住记录
|
||||||
const checkInDate = row.checkInDate?.trim() || new Date().toISOString().split('T')[0];
|
const checkInDate = row.checkInDate?.trim() || new Date().toISOString().split('T')[0];
|
||||||
const occData: any = {
|
const occData: any = {
|
||||||
studentId: student.id,
|
studentId: student.id,
|
||||||
roomId: room.id,
|
roomId: room.id,
|
||||||
checkInDate,
|
checkInDate,
|
||||||
billingStartDate: checkInDate,
|
billingStartDate: row.billingStartDate?.trim() || checkInDate,
|
||||||
|
stayType: row.stayType || undefined,
|
||||||
responsibleOrganizationId: student.organizationId || organization.id,
|
responsibleOrganizationId: student.organizationId || organization.id,
|
||||||
|
notes: row.notes || undefined,
|
||||||
|
bedId: bed?.id,
|
||||||
|
lockerId: locker?.id,
|
||||||
};
|
};
|
||||||
// 如果有退宿日期,直接记录
|
// 如果有退宿日期,直接记录
|
||||||
if (row.checkOutDate?.trim()) {
|
if (row.checkOutDate?.trim()) {
|
||||||
@@ -466,9 +511,13 @@ export class OccupanciesService {
|
|||||||
}
|
}
|
||||||
await this.repo.save(this.repo.create(occData));
|
await this.repo.save(this.repo.create(occData));
|
||||||
|
|
||||||
// 8. 更新宿舍状态
|
// 7. 更新床位、柜子和宿舍状态
|
||||||
if (!row.checkOutDate?.trim() && count + 1 >= room.capacity) {
|
if (!isHistoricalRecord) {
|
||||||
await this.roomRepo.update(room.id, { status: 'full' });
|
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. 自动收取押金(仅对新入住且非历史记录的学生)
|
// 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(accommodation.groups).toEqual(
|
||||||
expect.arrayContaining(['room', 'occupancy', 'expense', 'bill', 'deposit']),
|
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', () => {
|
it('keeps classroom rental operations separate from accommodation operations', () => {
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ export const PRESET_ROLES: Array<{
|
|||||||
'notification',
|
'notification',
|
||||||
'profile',
|
'profile',
|
||||||
],
|
],
|
||||||
extraPermissions: ['student:basic-view'],
|
extraPermissions: ['student:basic-view', 'organization:view'],
|
||||||
legacyNames: ['宿管老师', '宿管', '财务'],
|
legacyNames: ['宿管老师', '宿管', '财务'],
|
||||||
legacyCodes: ['dormitory_supervisor', 'dorm_manager', 'finance'],
|
legacyCodes: ['dormitory_supervisor', 'dorm_manager', 'finance'],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user