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="默认为短租" /> - - { }))} /> + + + + 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'], },