diff --git a/apps/admin/src/pages/IntegrationConfig/index.tsx b/apps/admin/src/pages/IntegrationConfig/index.tsx
index 7226466..71987f8 100644
--- a/apps/admin/src/pages/IntegrationConfig/index.tsx
+++ b/apps/admin/src/pages/IntegrationConfig/index.tsx
@@ -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 = () => {
-
-
-
diff --git a/apps/admin/src/pages/Occupancies/index.tsx b/apps/admin/src/pages/Occupancies/index.tsx
index 1ce8cdf..a8d8ab5 100644
--- a/apps/admin/src/pages/Occupancies/index.tsx
+++ b/apps/admin/src/pages/Occupancies/index.tsx
@@ -40,7 +40,6 @@ const OccupanciesPage: React.FC = () => {
const [data, setData] = useState([]);
const [students, setStudents] = useState([]);
const [rooms, setRooms] = useState([]);
- const [organizations, setOrganizations] = useState([]);
const [loading, setLoading] = useState(false);
const [checkInModal, setCheckInModal] = useState(false);
const [checkOutModal, setCheckOutModal] = useState(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[];
- 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 = () => {
{
icon={}
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 = () => {
}
}}
>
-
+
}>
导入入住名单
@@ -598,18 +596,6 @@ const OccupanciesPage: React.FC = () => {
placeholder="默认为短租"
/>
-
-
{
空闲 {availableBeds.length} 张床位
)}
-
+
+
+
+
+ prev.collectDeposit !== current.collectDeposit}>
+ {({ getFieldValue }) =>
+ getFieldValue('collectDeposit') ? (
+
+
+
+ ) : null
+ }
+
diff --git a/apps/server/src/occupancies/dto/occupancy.dto.spec.ts b/apps/server/src/occupancies/dto/occupancy.dto.spec.ts
index 5e07f45..24b7df8 100644
--- a/apps/server/src/occupancies/dto/occupancy.dto.spec.ts
+++ b/apps/server/src/occupancies/dto/occupancy.dto.spec.ts
@@ -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,
diff --git a/apps/server/src/occupancies/dto/occupancy.dto.ts b/apps/server/src/occupancies/dto/occupancy.dto.ts
index 8aafeec..1cc6dab 100644
--- a/apps/server/src/occupancies/dto/occupancy.dto.ts
+++ b/apps/server/src/occupancies/dto/occupancy.dto.ts
@@ -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;
diff --git a/apps/server/src/occupancies/occupancies.controller.ts b/apps/server/src/occupancies/occupancies.controller.ts
index 64aae12..bdd3a96 100644
--- a/apps/server/src/occupancies/occupancies.controller.ts
+++ b/apps/server/src/occupancies/occupancies.controller.ts
@@ -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(
diff --git a/apps/server/src/occupancies/occupancies.service.spec.ts b/apps/server/src/occupancies/occupancies.service.spec.ts
index 98ed001..0ce5fea 100644
--- a/apps/server/src/occupancies/occupancies.service.spec.ts
+++ b/apps/server/src/occupancies/occupancies.service.spec.ts
@@ -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;
+ const roomRepo = {
+ findOne: jest.fn().mockResolvedValue({ id: 2, capacity: 4 }),
+ update: jest.fn(),
+ } as any as Repository;
+ const studentRepo = {
+ findOne: jest.fn().mockResolvedValue({ id: 3, organizationId: 7 }),
+ } as any as Repository;
+ const depositRepo = {
+ findOne: jest.fn().mockResolvedValue(existingDeposit),
+ create: jest.fn((value) => value),
+ save: jest.fn(async (value) => ({ ...value, id: 20 })),
+ } as any as Repository;
+ const bedRepo = {
+ findOne: jest.fn().mockResolvedValue({ id: 4, roomId: 2, status: 'available' }),
+ update: jest.fn(),
+ } as any as Repository;
+
+ return {
+ service: new OccupanciesService(
+ occupancyRepo,
+ roomRepo,
+ studentRepo,
+ depositRepo,
+ bedRepo,
+ {} as Repository,
+ {} as Repository,
+ {} 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;
+ 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;
+ const studentRepo = {
+ findOne: jest.fn().mockResolvedValue(existingStudent),
+ create: jest.fn((value) => value),
+ save: jest.fn(),
+ update: jest.fn(),
+ } as any as Repository;
+ 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;
+ const organizationRepo = {
+ findOne: jest.fn(),
+ create: jest.fn((value) => value),
+ save: jest.fn(),
+ } as any as Repository;
+
+ const service = new OccupanciesService(
+ occupancyRepo,
+ roomRepo,
+ studentRepo,
+ { findOne: jest.fn() } as any as Repository,
+ bedRepo,
+ { findOne: jest.fn() } as any as Repository,
+ 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 }));
+ });
+});
diff --git a/apps/server/src/occupancies/occupancies.service.ts b/apps/server/src/occupancies/occupancies.service.ts
index 9c51b97..d9b5697 100644
--- a/apps/server/src/occupancies/occupancies.service.ts
+++ b/apps/server/src/occupancies/occupancies.service.ts
@@ -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,
diff --git a/apps/server/src/occupancies/occupancy-import-template.spec.ts b/apps/server/src/occupancies/occupancy-import-template.spec.ts
index 9d4befa..c65a5cc 100644
--- a/apps/server/src/occupancies/occupancy-import-template.spec.ts
+++ b/apps/server/src/occupancies/occupancy-import-template.spec.ts
@@ -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('入住名单导入模板')!;
diff --git a/apps/server/src/occupancies/occupancy-import-template.ts b/apps/server/src/occupancies/occupancy-import-template.ts
index 286eb76..df43fe3 100644
--- a/apps/server/src/occupancies/occupancy-import-template.ts
+++ b/apps/server/src/occupancies/occupancy-import-template.ts
@@ -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 = {
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 };
diff --git a/apps/server/src/rbac/rbac.permissions.spec.ts b/apps/server/src/rbac/rbac.permissions.spec.ts
index 0930ab7..4c14765 100644
--- a/apps/server/src/rbac/rbac.permissions.spec.ts
+++ b/apps/server/src/rbac/rbac.permissions.spec.ts
@@ -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', () => {
diff --git a/apps/server/src/rbac/rbac.service.ts b/apps/server/src/rbac/rbac.service.ts
index 3ef7875..36b8d7c 100644
--- a/apps/server/src/rbac/rbac.service.ts
+++ b/apps/server/src/rbac/rbac.service.ts
@@ -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'],
},