Merge pull request 'feat: 优化入住办理与名单导入' (#9) from codex/wzq into main

This commit is contained in:
2026-07-14 04:26:53 +00:00
11 changed files with 322 additions and 78 deletions

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState, useMemo, useCallback } from 'react';
import {
Card, Form, Input, Button, Space, Spin, Alert, Descriptions, Tag, Divider,
Drawer, Tree, Select, TreeSelect, Modal, DatePicker, InputNumber,
Drawer, Tree, Select, TreeSelect, Modal, DatePicker,
Row, Col, List,
} from 'antd';
import {
@@ -59,7 +59,6 @@ interface ClassItem {
classType?: string;
startDate?: string;
endDate?: string;
maxStudents?: number;
notes?: string;
}
@@ -477,9 +476,6 @@ const IntegrationConfigPage: React.FC = () => {
<Form.Item name="endDate" label="结束日期">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="maxStudents" label="最大人数">
<InputNumber min={0} style={{ width: '100%' }} placeholder="0=不限制" />
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>

View File

@@ -40,7 +40,6 @@ const OccupanciesPage: React.FC = () => {
const [data, setData] = useState<any[]>([]);
const [students, setStudents] = useState<any[]>([]);
const [rooms, setRooms] = useState<any[]>([]);
const [organizations, setOrganizations] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [checkInModal, setCheckInModal] = useState(false);
const [checkOutModal, setCheckOutModal] = useState<any>(null);
@@ -66,14 +65,13 @@ const OccupanciesPage: React.FC = () => {
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [occRes, stuRes, rmRes, tnRes] = (await Promise.allSettled([
const [occRes, stuRes, rmRes] = (await Promise.allSettled([
api.get('/occupancies', { params: { active: showActive ? 'true' : undefined, dateFrom: dateRange?.[0]?.format('YYYY-MM-DD'), dateTo: dateRange?.[1]?.format('YYYY-MM-DD') } }),
api.get('/students/basic-lookups'),
api.get('/rooms/overview'),
api.get('/organizations'),
])) as PromiseSettledResult<any>[];
const labels = ['入住数据', '学生列表', '房间列表', '机构列表'];
[occRes, stuRes, rmRes, tnRes].forEach((res, i) => {
const labels = ['入住数据', '学生列表', '房间列表'];
[occRes, stuRes, rmRes].forEach((res, i) => {
if (res.status === 'rejected') {
message.warning(`${labels[i]}加载失败`);
}
@@ -81,7 +79,6 @@ const OccupanciesPage: React.FC = () => {
setData(occRes.status === 'fulfilled' ? occRes.value : []);
setStudents(stuRes.status === 'fulfilled' ? stuRes.value : []);
setRooms(rmRes.status === 'fulfilled' ? rmRes.value : []);
setOrganizations(tnRes.status === 'fulfilled' ? tnRes.value : []);
} catch (e) {
console.error(e);
message.error('数据加载异常');
@@ -157,7 +154,8 @@ const OccupanciesPage: React.FC = () => {
checkInDate: values.checkInDate.format('YYYY-MM-DD'),
billingStartDate: values.billingStartDate?.format('YYYY-MM-DD'),
stayType: values.stayType,
responsibleOrganizationId: values.responsibleOrganizationId,
collectDeposit: values.collectDeposit,
depositAmount: values.collectDeposit ? values.depositAmount : undefined,
notes: values.notes,
bedId: values.bedId,
lockerId: values.lockerId || undefined,
@@ -330,7 +328,7 @@ const OccupanciesPage: React.FC = () => {
<div>
<Alert
title="一站式导入"
description="导入入住名单时会自动创建学生和宿舍,无需单独在「学生管理」或「宿舍管理」中手动添加。后续仅需在此页面处理换房/退宿等日常操作即可。"
description="导入入住名单时会优先按手机号关联已有学生,所属机构自动取学生档案;未找到学生或宿舍时会自动创建。后续仅需在此页面处理换房/退宿等日常操作即可。"
type="info"
showIcon
closable
@@ -367,7 +365,7 @@ const OccupanciesPage: React.FC = () => {
icon={<PlusOutlined />}
onClick={() => {
checkInForm.resetFields();
checkInForm.setFieldsValue({ checkInDate: dayjs() });
checkInForm.setFieldsValue({ checkInDate: dayjs(), collectDeposit: true, depositAmount: 500 });
setCheckInModal(true);
}}
>
@@ -407,7 +405,7 @@ const OccupanciesPage: React.FC = () => {
}
}}
>
<Tooltip title="导入时自动创建学生、宿舍和入住记录">
<Tooltip title="按手机号关联学生,并自动创建缺失的学生、宿舍和入住记录">
<Button type="primary" ghost icon={<UploadOutlined />}>
</Button>
@@ -598,18 +596,6 @@ const OccupanciesPage: React.FC = () => {
placeholder="默认为短租"
/>
</Form.Item>
<Form.Item name="responsibleOrganizationId" label="所属机构">
<Select
showSearch
allowClear
optionFilterProp="label"
placeholder="默认取学生所属机构"
options={organizations.map((t: { id: number; name: string }) => ({
value: t.id,
label: t.name,
}))}
/>
</Form.Item>
<Form.Item
name="bedId"
label="床位"
@@ -630,7 +616,10 @@ const OccupanciesPage: React.FC = () => {
{availableBeds.length}
</div>
)}
<Form.Item name="lockerId" label="柜子(可选)">
<Form.Item
name="lockerId"
label="柜子(可选)"
>
<Select
allowClear
placeholder="可选分配柜子"
@@ -641,6 +630,32 @@ const OccupanciesPage: React.FC = () => {
}))}
/>
</Form.Item>
<Form.Item
name="collectDeposit"
label="押金缴纳"
valuePropName="checked"
extra="开启后,确认入住时同步生成已缴押金记录;已有已缴押金时不会重复创建"
>
<Switch checkedChildren="已缴" unCheckedChildren="不缴" />
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, current) => prev.collectDeposit !== current.collectDeposit}>
{({ getFieldValue }) =>
getFieldValue('collectDeposit') ? (
<Form.Item
name="depositAmount"
label="押金金额"
rules={[{ required: true, message: '请输入押金金额' }]}
>
<InputNumber
min={0.01}
precision={2}
addonAfter="元"
style={{ width: '100%' }}
/>
</Form.Item>
) : null
}
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>

View File

@@ -1,3 +1,4 @@
import { ValidationPipe } from '@nestjs/common';
import { validate } from 'class-validator';
import { CheckInDto, TransferRoomDto } from './occupancy.dto';
@@ -14,6 +15,50 @@ describe('manual occupancy DTO bed requirements', () => {
expect(errors.some((error) => error.property === 'bedId')).toBe(true);
});
it('accepts optional deposit collection details for manual check-in', async () => {
const dto = Object.assign(new CheckInDto(), {
studentId: 1,
roomId: 2,
checkInDate: '2026-07-13',
bedId: 3,
collectDeposit: true,
depositAmount: 500,
});
await expect(validate(dto)).resolves.toHaveLength(0);
});
it('rejects a non-positive deposit amount', async () => {
const dto = Object.assign(new CheckInDto(), {
studentId: 1,
roomId: 2,
checkInDate: '2026-07-13',
bedId: 3,
collectDeposit: true,
depositAmount: 0,
});
const errors = await validate(dto);
expect(errors.some((error) => error.property === 'depositAmount')).toBe(true);
});
it('strips a manually supplied responsible organization', async () => {
const pipe = new ValidationPipe({ transform: true, whitelist: true });
const dto = await pipe.transform(
{
studentId: 1,
roomId: 2,
checkInDate: '2026-07-13',
bedId: 3,
responsibleOrganizationId: 99,
},
{ type: 'body', metatype: CheckInDto },
);
expect(dto).not.toHaveProperty('responsibleOrganizationId');
});
it('requires a new bed for a room transfer while keeping the locker optional', async () => {
const dto = Object.assign(new TransferRoomDto(), {
newRoomId: 3,

View File

@@ -1,4 +1,4 @@
import { IsInt, IsString, IsOptional, IsArray } from 'class-validator';
import { IsArray, IsBoolean, IsInt, IsNumber, IsOptional, IsString, Min } from 'class-validator';
export class CheckInDto {
@IsInt()
@@ -23,8 +23,14 @@ export class CheckInDto {
stayType?: string;
@IsOptional()
@IsInt()
responsibleOrganizationId?: number;
@IsBoolean()
collectDeposit?: boolean;
@IsOptional()
@IsNumber()
@Min(0.01)
depositAmount?: number;
@IsInt()
bedId: number;

View File

@@ -77,7 +77,7 @@ export class OccupanciesController {
@RequirePermission('occupancy:checkin')
async checkIn(@Body() dto: CheckInDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.checkIn(dto);
const result = await this.service.checkIn(dto, req.user?.id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
@@ -201,17 +201,20 @@ export class OccupanciesController {
ws.columns = [
{ header: '宿舍号', key: 'roomNumber', width: 12 },
{ header: '楼栋', key: 'building', width: 12 },
{ header: '床位号', key: 'bedNumber', width: 10 },
{ header: '柜子号', key: 'lockerNumber', width: 10 },
{ header: '学生姓名', key: 'studentName', width: 12 },
{ header: '性别', key: 'gender', width: 8 },
{ header: '电话', key: 'phone', width: 18 },
{ header: '学号/身份证', key: 'idNumber', width: 22 },
{ header: '所属机构', key: 'organization', width: 18 },
{ header: '负责人/班主任', key: 'supervisor', width: 15 },
{ header: '入住日期', key: 'checkInDate', width: 14 },
{ header: '退宿日期', key: 'checkOutDate', width: 14 },
{ header: '计费起始', key: 'billingStartDate', width: 14 },
{ header: '计费截止', key: 'billingEndDate', width: 14 },
{ header: '退宿原因', key: 'checkOutReason', width: 12 },
{ header: '入住类型', key: 'stayType', width: 10 },
{ header: '退宿原因', key: 'checkOutReason', width: 16 },
{ header: '备注', key: 'notes', width: 24 },
];
ws.getRow(1).font = { bold: true };
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
@@ -219,17 +222,20 @@ export class OccupanciesController {
ws.addRow({
roomNumber: r.room?.roomNumber || '',
building: r.room?.building || '',
bedNumber: r.bed?.bedNumber || '',
lockerNumber: r.locker?.lockerNumber || '',
studentName: r.student?.name || '',
gender: r.student?.gender || '',
phone: r.student?.phone || '',
idNumber: r.student?.idNumber || '',
organization: r.student?.organization?.name || '',
supervisor: r.student?.supervisor || '',
checkInDate: r.checkInDate || '',
checkOutDate: r.checkOutDate || '',
billingStartDate: r.billingStartDate || '',
billingEndDate: r.billingEndDate || '',
stayType: r.stayType === 'long' ? '长租' : '短租',
checkOutReason: r.checkOutReason || '',
notes: r.notes || '',
});
}
res!.setHeader(

View File

@@ -8,7 +8,7 @@ import { Bed } from '../entities/bed.entity';
import { Locker } from '../entities/locker.entity';
describe('OccupanciesService — responsible organization', () => {
it('defaults the responsible organization to the student organization', async () => {
it('always takes the responsible organization from the student', async () => {
const occupancyRepo = {
findOne: jest.fn().mockResolvedValue(null),
count: jest.fn().mockResolvedValue(0),
@@ -42,7 +42,8 @@ describe('OccupanciesService — responsible organization', () => {
roomId: 2,
checkInDate: '2026-07-10',
bedId: 4,
});
responsibleOrganizationId: 99,
} as any);
expect(occupancyRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ responsibleOrganizationId: 7 }),
@@ -50,6 +51,89 @@ describe('OccupanciesService — responsible organization', () => {
});
});
describe('OccupanciesService — manual check-in deposit', () => {
const createService = (existingDeposit: Deposit | null = null) => {
const occupancyRepo = {
findOne: jest.fn().mockResolvedValue(null),
count: jest.fn().mockResolvedValue(0),
create: jest.fn((value) => value),
save: jest.fn(async (value) => ({ ...value, id: 10 })),
} as any as Repository<Occupancy>;
const roomRepo = {
findOne: jest.fn().mockResolvedValue({ id: 2, capacity: 4 }),
update: jest.fn(),
} as any as Repository<Room>;
const studentRepo = {
findOne: jest.fn().mockResolvedValue({ id: 3, organizationId: 7 }),
} as any as Repository<Student>;
const depositRepo = {
findOne: jest.fn().mockResolvedValue(existingDeposit),
create: jest.fn((value) => value),
save: jest.fn(async (value) => ({ ...value, id: 20 })),
} as any as Repository<Deposit>;
const bedRepo = {
findOne: jest.fn().mockResolvedValue({ id: 4, roomId: 2, status: 'available' }),
update: jest.fn(),
} as any as Repository<Bed>;
return {
service: new OccupanciesService(
occupancyRepo,
roomRepo,
studentRepo,
depositRepo,
bedRepo,
{} as Repository<Locker>,
{} as Repository<any>,
{} as DataSource,
),
depositRepo,
};
};
it('creates a paid deposit together with manual check-in', async () => {
const { service, depositRepo } = createService();
await service.checkIn(
{
studentId: 3,
roomId: 2,
checkInDate: '2026-07-14',
bedId: 4,
collectDeposit: true,
depositAmount: 800,
},
11,
);
expect(depositRepo.create).toHaveBeenCalledWith({
studentId: 3,
amount: 800,
paidDate: '2026-07-14',
status: 'paid',
recordedBy: 11,
notes: '入住登记自动收取',
});
expect(depositRepo.save).toHaveBeenCalledTimes(1);
});
it('does not create another paid deposit when one already exists', async () => {
const { service, depositRepo } = createService({ id: 99 } as Deposit);
await service.checkIn({
studentId: 3,
roomId: 2,
checkInDate: '2026-07-14',
bedId: 4,
collectDeposit: true,
depositAmount: 800,
});
expect(depositRepo.create).not.toHaveBeenCalled();
expect(depositRepo.save).not.toHaveBeenCalled();
});
});
describe('OccupanciesService — import bed capacity', () => {
it('rejects creating a new bed when the room already has its capacity in beds', async () => {
const occupancyRepo = {
@@ -97,6 +181,7 @@ describe('OccupanciesService — import bed capacity', () => {
const result = await service.batchImportCheckIn([
{
name: '张三',
phone: '13800138000',
roomNumber: '4-102',
bedNumber: '5号床',
checkInDate: '2026-07-14',
@@ -114,3 +199,78 @@ describe('OccupanciesService — import bed capacity', () => {
expect(occupancyRepo.save).not.toHaveBeenCalled();
});
});
describe('OccupanciesService — import student matching', () => {
it('associates an existing student by phone and keeps the student organization', async () => {
const existingStudent = {
id: 3,
name: '学生档案姓名',
phone: '13800138000',
organizationId: 7,
};
const occupancyRepo = {
findOne: jest.fn().mockResolvedValue(null),
count: jest.fn().mockResolvedValue(0),
create: jest.fn((value) => value),
save: jest.fn(async (value) => ({ ...value, id: 10 })),
} 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(existingStudent),
create: jest.fn((value) => value),
save: jest.fn(),
update: jest.fn(),
} as any as Repository<Student>;
const bedRepo = {
findOne: jest.fn().mockResolvedValue({
id: 4,
roomId: 2,
bedNumber: '1号床',
status: 'available',
}),
count: jest.fn(),
create: jest.fn((value) => value),
save: jest.fn(),
update: jest.fn(),
} as any as Repository<Bed>;
const organizationRepo = {
findOne: jest.fn(),
create: jest.fn((value) => value),
save: jest.fn(),
} as any as Repository<any>;
const service = new OccupanciesService(
occupancyRepo,
roomRepo,
studentRepo,
{ findOne: 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: 'Excel姓名',
phone: '13800138000',
roomNumber: '4-102',
bedNumber: '1号床',
checkInDate: '2026-07-14',
},
]);
expect(studentRepo.findOne).toHaveBeenCalledWith({ where: { phone: '13800138000' } });
expect(studentRepo.save).not.toHaveBeenCalled();
expect(organizationRepo.findOne).not.toHaveBeenCalled();
expect(occupancyRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ studentId: 3, responsibleOrganizationId: 7 }),
);
expect(result).toEqual(expect.objectContaining({ imported: 1, skipped: 0 }));
});
});

View File

@@ -16,7 +16,6 @@ import { Bed } from '../entities/bed.entity';
import { Locker } from '../entities/locker.entity';
import { Deposit } from '../entities/deposit.entity';
import { Organization } from '../entities/organization.entity';
import { uuidV7 } from '../common/uuid-v7';
import { CheckInDto, CheckOutDto, TransferRoomDto } from './dto/occupancy.dto';
import { RoomsService } from '../rooms/rooms.service';
@@ -40,7 +39,6 @@ export class OccupanciesService {
.leftJoinAndSelect('o.room', 'room')
.leftJoinAndSelect('o.bed', 'bed')
.leftJoinAndSelect('o.locker', 'locker')
.leftJoinAndSelect('o.responsibleOrganization', 'responsibleOrganization')
.orderBy('o.checkInDate', 'DESC');
if (query?.roomId) qb.andWhere('o.roomId = :roomId', { roomId: query.roomId });
if (query?.studentId) qb.andWhere('o.studentId = :studentId', { studentId: query.studentId });
@@ -48,7 +46,7 @@ export class OccupanciesService {
return qb.getMany();
}
async checkIn(dto: CheckInDto) {
async checkIn(dto: CheckInDto, userId?: number) {
// 检查学生是否已有活跃入住
const existing = await this.repo.findOne({
where: { studentId: dto.studentId, checkOutDate: IsNull() },
@@ -86,7 +84,7 @@ export class OccupanciesService {
checkInDate: dto.checkInDate,
billingStartDate: dto.billingStartDate || dto.checkInDate,
stayType: dto.stayType,
responsibleOrganizationId: dto.responsibleOrganizationId ?? student.organizationId,
responsibleOrganizationId: student.organizationId,
notes: dto.notes,
bedId: dto.bedId,
lockerId: dto.lockerId,
@@ -105,6 +103,25 @@ export class OccupanciesService {
if (count + 1 >= room.capacity) {
await this.roomRepo.update(room.id, { status: 'full' });
}
if (dto.collectDeposit) {
const existingDeposit = await this.depositRepo.findOne({
where: { studentId: dto.studentId, status: 'paid' },
});
if (!existingDeposit) {
await this.depositRepo.save(
this.depositRepo.create({
studentId: dto.studentId,
amount: dto.depositAmount ?? 500,
paidDate: dto.checkInDate,
status: 'paid',
recordedBy: userId,
notes: '入住登记自动收取',
}),
);
}
}
return saved;
}
@@ -336,7 +353,6 @@ export class OccupanciesService {
ethnicity?: string;
emergencyContact?: string;
emergencyPhone?: string;
organization?: string;
supervisor?: string;
roomNumber: string;
building?: string;
@@ -365,42 +381,33 @@ export class OccupanciesService {
}
try {
// 1. 解析所属机构;未填写时默认本机构
let organization = row.organization?.trim()
? await this.organizationRepo.findOne({ where: { name: row.organization.trim() } })
: await this.organizationRepo.findOne({ where: { isHost: true, status: 'active' } });
if (!organization && row.organization?.trim()) {
organization = await this.organizationRepo.save(
this.organizationRepo.create({
publicId: uuidV7(),
code: `ORG_${Date.now()}_${i}`,
name: row.organization.trim(),
isHost: false,
status: 'active',
}),
);
}
if (!organization) throw new BadRequestException('尚未配置本机构');
// 1. 通过手机号关联学生;未找到时创建学生并归入本机构
const phone = row.phone?.trim();
if (!phone) throw new BadRequestException('手机号不能为空,无法关联学生');
let student = await this.studentRepo.findOne({ where: { name: row.name.trim() } });
let student = await this.studentRepo.findOne({ where: { phone } });
if (!student) {
const hostOrganization = await this.organizationRepo.findOne({
where: { isHost: true, status: 'active' },
});
if (!hostOrganization) throw new BadRequestException('尚未配置本机构');
student = await this.studentRepo.save(
this.studentRepo.create({
name: row.name.trim(),
phone: row.phone?.trim() || undefined,
phone,
idNumber: row.idNumber?.trim() || undefined,
gender: row.gender?.trim() || undefined,
ethnicity: row.ethnicity?.trim() || undefined,
emergencyContact: row.emergencyContact?.trim() || undefined,
emergencyPhone: row.emergencyPhone?.trim() || undefined,
organizationId: organization.id,
organizationId: hostOrganization.id,
supervisor: row.supervisor?.trim() || undefined,
}),
);
} else {
// 更新已有学生的缺失信息
const updates: any = {};
if (!student.phone && row.phone?.trim()) updates.phone = row.phone.trim();
if (!student.idNumber && row.idNumber?.trim()) updates.idNumber = row.idNumber.trim();
if (!student.gender && row.gender?.trim()) updates.gender = row.gender.trim();
if (!student.ethnicity && row.ethnicity?.trim()) updates.ethnicity = row.ethnicity.trim();
@@ -408,7 +415,6 @@ export class OccupanciesService {
updates.emergencyContact = row.emergencyContact.trim();
if (!student.emergencyPhone && row.emergencyPhone?.trim())
updates.emergencyPhone = row.emergencyPhone.trim();
if (!student.organizationId) updates.organizationId = organization.id;
if (!student.supervisor && row.supervisor?.trim())
updates.supervisor = row.supervisor.trim();
if (Object.keys(updates).length > 0) {
@@ -499,7 +505,7 @@ export class OccupanciesService {
checkInDate,
billingStartDate: row.billingStartDate?.trim() || checkInDate,
stayType: row.stayType || undefined,
responsibleOrganizationId: student.organizationId || organization.id,
responsibleOrganizationId: student.organizationId,
notes: row.notes || undefined,
bedId: bed?.id,
lockerId: locker?.id,

View File

@@ -17,13 +17,26 @@ describe('occupancy import template', () => {
'床位号',
'柜子号',
'计费起始日',
'电话',
'入住类型',
'备注',
]),
);
expect(headers).not.toContain('所属机构');
expect(ws.columnCount).toBe(OCCUPANCY_IMPORT_COLUMNS.length);
});
it('documents the current phone matching, organization, and deposit behavior', () => {
const workbook = createOccupancyImportTemplateWorkbook();
const helpWs = workbook.getWorksheet('使用说明')!;
const instructions = helpWs.getColumn(1).values.join('\n');
expect(instructions).toContain('按手机号关联已有学生');
expect(instructions).toContain('所属机构自动取学生档案');
expect(instructions).toContain('导入时自动收押金');
expect(instructions).toContain('历史入住不会自动收取');
});
it('keeps Excel Date cells on the same local calendar day', () => {
const workbook = createOccupancyImportTemplateWorkbook();
const ws = workbook.getWorksheet('入住名单导入模板')!;

View File

@@ -16,7 +16,6 @@ export interface OccupancyImportRow {
stayType?: string;
emergencyContact?: string;
emergencyPhone?: string;
organization?: string;
supervisor?: string;
notes?: string;
}
@@ -37,7 +36,6 @@ export const OCCUPANCY_IMPORT_COLUMNS = [
{ 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;
@@ -58,7 +56,6 @@ const HEADER_ALIASES: Record<keyof OccupancyImportRow, string[]> = {
stayType: ['入住类型', '住宿类型'],
emergencyContact: ['紧急联系人'],
emergencyPhone: ['紧急联系人电话', '紧急联系电话'],
organization: ['所属机构', '机构'],
supervisor: ['负责人/班主任', '负责人', '班主任'],
notes: ['备注'],
};
@@ -137,7 +134,6 @@ export function parseOccupancyImportWorksheet(ws: ExcelJS.Worksheet): OccupancyI
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,
});
@@ -152,7 +148,10 @@ export function createOccupancyImportTemplateWorkbook(): ExcelJS.Workbook {
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.autoFilter = {
from: 'A1',
to: `${ws.getColumn(OCCUPANCY_IMPORT_COLUMNS.length).letter}1`,
};
ws.addRow({
roomNumber: '4-102',
@@ -170,7 +169,6 @@ export function createOccupancyImportTemplateWorkbook(): ExcelJS.Workbook {
stayType: '短租',
emergencyContact: '张父',
emergencyPhone: '13900000000',
organization: '',
supervisor: '',
notes: '',
});
@@ -190,7 +188,6 @@ export function createOccupancyImportTemplateWorkbook(): ExcelJS.Workbook {
stayType: '长租',
emergencyContact: '',
emergencyPhone: '',
organization: 'XXX教育科技',
supervisor: '王老师',
notes: '示例数据,导入前请删除',
});
@@ -208,15 +205,16 @@ export function createOccupancyImportTemplateWorkbook(): ExcelJS.Workbook {
helpWs.getColumn(1).width = 90;
const instructions = [
'【入住名单导入说明】',
'1. 宿舍号、姓名、入住时间为必填项;床位号建议填写,柜子号可选。',
'1. 宿舍号、姓名、手机号、入住时间为必填项;床位号建议填写,柜子号可选。',
'2. 填写床位号或柜子号后,系统会在对应宿舍中匹配;不存在时自动创建,已被占用时该行导入失败。',
'3. 宿舍不存在时会自动创建;宿舍号可智能解析楼栋、楼层和房间类型,楼栋列可用于补充楼栋名称。',
'4. 同一宿舍号的连续多行可以合并或留空,系统会继承上一行宿舍号。',
'5. 入住类型可填“短租”或“长租”;计费起始日不填时默认等于入住时间。',
'6. 已存在的学生按姓名匹配,并自动补充其缺失的基础资料。',
'6. 系统按手机号关联已有学生,入住记录的所属机构自动取学生档案;未找到时会新建学生。',
'7. 已有在住记录的学生会自动跳过,不会重复入住。',
'8. 填写离宿时间的记录会作为历史入住导入,床位和柜子不会被标记为占用。',
'9. 模板中的两行示例数据仅用于说明,正式导入前请删除或替换。',
'9. 押金不在表格中逐行填写;请在上传前使用页面上的“导入时自动收押金”和金额设置,历史入住不会自动收取,已有已缴押金不会重复创建。',
'10. 模板中的两行示例数据仅用于说明,正式导入前请删除或替换。',
];
instructions.forEach((instruction) => helpWs.addRow([instruction]));
helpWs.getRow(1).font = { bold: true, size: 14 };

View File

@@ -42,9 +42,8 @@ describe('preset role permissions', () => {
expect(accommodation.groups).toEqual(
expect.arrayContaining(['room', 'occupancy', 'expense', 'bill', 'deposit']),
);
expect(accommodation.extras).toEqual(
expect.arrayContaining(['student:basic-view', 'organization:view']),
);
expect(accommodation.extras).toContain('student:basic-view');
expect(accommodation.extras).not.toContain('organization:view');
});
it('keeps classroom rental operations separate from accommodation operations', () => {

View File

@@ -180,7 +180,7 @@ export const PRESET_ROLES: Array<{
'notification',
'profile',
],
extraPermissions: ['student:basic-view', 'organization:view'],
extraPermissions: ['student:basic-view'],
legacyNames: ['宿管老师', '宿管', '财务'],
legacyCodes: ['dormitory_supervisor', 'dorm_manager', 'finance'],
},