forked from wangziqi/gongxue-base
feat: streamline occupancy check-in and imports
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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('入住名单导入模板')!;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user