增加宿舍查寝功能 #34

Merged
wangziqi merged 7 commits from xiongyuxing/gongxue-base:main into main 2026-07-22 03:33:32 +00:00
26 changed files with 983 additions and 40 deletions

View File

@@ -17,7 +17,7 @@ on:
jobs:
check:
runs-on: ubuntu-latest
container: node:20.20.2-bookworm
container: node:22.22.0-bookworm
timeout-minutes: 30
steps:
@@ -33,7 +33,7 @@ jobs:
- name: Lint
run: |
npm run lint -w @gongxue/admin
npm run lint -w @gongxue/server -- --no-fix
npm run lint -w @gongxue/server -- --quiet
- name: Type check
run: npm run typecheck

View File

@@ -13,6 +13,8 @@ import {
DatePicker,
Alert,
Button,
Switch,
Space,
} from 'antd';
import {
HomeOutlined,
@@ -21,10 +23,15 @@ import {
BankOutlined,
HistoryOutlined,
ShopOutlined,
CheckCircleOutlined,
SaveOutlined,
} from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
import api from '../../api';
import { message } from '../../ui/app-message';
import PermissionButton from '../../components/PermissionButton';
import { usePermission } from '../../hooks/usePermission';
import { getInitialPresentOccupancyIds, togglePresentOccupancy } from './inspection-state';
function getCardStyle(room: any): React.CSSProperties {
let base: React.CSSProperties;
@@ -84,6 +91,9 @@ const RoomVisualPage: React.FC = () => {
const [selectedOrganization, setSelectedOrganization] = useState<number | 'all'>('all');
const [detailRoom, setDetailRoom] = useState<any>(null);
const [asOf, setAsOf] = useState<Dayjs | null>(null);
const [presentOccupancyIds, setPresentOccupancyIds] = useState<number[]>([]);
const [inspectionSaving, setInspectionSaving] = useState(false);
const { hasPermission } = usePermission();
const isHistorical = !!asOf && !asOf.isSame(dayjs(), 'day');
@@ -104,6 +114,42 @@ const RoomVisualPage: React.FC = () => {
fetchData();
}, [fetchData]);
useEffect(() => {
if (!detailRoom) {
setPresentOccupancyIds([]);
return;
}
setPresentOccupancyIds(
getInitialPresentOccupancyIds(
detailRoom.occupants || [],
detailRoom.inspection?.submitted === true,
),
);
}, [detailRoom]);
const inspectionDate = (asOf || dayjs()).format('YYYY-MM-DD');
const submitInspection = async () => {
if (!detailRoom || isHistorical) return;
setInspectionSaving(true);
try {
await api.put(`/rooms/${detailRoom.id}/inspections/${inspectionDate}`, {
presentOccupancyIds,
});
message.success(detailRoom.inspection?.submitted ? '查寝记录已更新' : '查寝已提交');
const params = isHistorical ? { asOf: inspectionDate } : undefined;
const res: any = await api.get('/rooms/visual', { params });
setData(res);
const updatedRoom = res.rooms.find((room: any) => room.id === detailRoom.id);
if (updatedRoom) setDetailRoom(updatedRoom);
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '查寝提交失败');
} finally {
setInspectionSaving(false);
}
};
if (!data) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
const rooms = data.rooms.filter((r: any) => {
@@ -345,6 +391,17 @@ const RoomVisualPage: React.FC = () => {
{room.occupants.length > 4 && <Tag>+{room.occupants.length - 4}</Tag>}
</div>
)}
{room.occupants.length > 0 && (
<div style={{ marginTop: 4 }}>
{room.inspection?.submitted ? (
<Tag color={room.inspection.source === 'automatic' ? 'orange' : 'green'}>
{room.inspection.source === 'automatic' ? '自动补记' : '已查寝'}
</Tag>
) : (
<Tag></Tag>
)}
</div>
)}
</Card>
</Col>
))}
@@ -388,9 +445,26 @@ const RoomVisualPage: React.FC = () => {
<div style={{ marginBottom: 16 }}>{getStatusLabel(detailRoom)}</div>
{detailRoom.occupants.length > 0 ? (
<div>
<h4 style={{ marginBottom: 8 }}></h4>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 12,
marginBottom: 8,
flexWrap: 'wrap',
}}
>
<h4 style={{ margin: 0 }}></h4>
{detailRoom.inspection?.submitted && (
<Tag color={detailRoom.inspection.source === 'automatic' ? 'orange' : 'green'}>
{detailRoom.inspection.source === 'automatic' ? '自动补记' : '已提交'} ·{' '}
{detailRoom.inspection.inspectorName}
</Tag>
)}
</div>
{detailRoom.occupants.map((o: any) => (
<Card key={o.studentId} size="small" style={{ marginBottom: 8, borderRadius: 8 }}>
<Card key={o.occupancyId} size="small" style={{ marginBottom: 8, borderRadius: 8 }}>
<div
style={{
display: 'flex',
@@ -407,10 +481,44 @@ const RoomVisualPage: React.FC = () => {
</Tag>
)}
</div>
<Tag color="blue">{o.days} </Tag>
{isHistorical ? (
<Tag
color={
o.inspectionStatus === 'present'
? 'green'
: o.inspectionStatus === 'absent'
? 'red'
: 'default'
}
>
{o.inspectionStatus === 'present'
? '在寝'
: o.inspectionStatus === 'absent'
? '缺勤'
: '无记录'}
</Tag>
) : (
<Space size="small">
<span style={{ color: '#86868b', fontSize: 12 }}>
{presentOccupancyIds.includes(o.occupancyId) ? '在寝' : '缺勤'}
</span>
<Switch
checked={presentOccupancyIds.includes(o.occupancyId)}
disabled={!hasPermission('room:inspect')}
checkedChildren="在寝"
unCheckedChildren="缺勤"
onChange={(checked) =>
setPresentOccupancyIds((current) =>
togglePresentOccupancy(current, o.occupancyId, checked),
)
}
/>
</Space>
)}
</div>
<div style={{ color: '#86868b', fontSize: 12, marginTop: 4 }}>
<CalendarOutlined style={{ marginRight: 4 }} />
{o.bedNumber || '未分配'} |{' '}
{o.checkInDate} | {o.billingStartDate}
{o.supervisor && (
<span style={{ marginLeft: 8 }}>{o.supervisor}</span>
@@ -418,6 +526,37 @@ const RoomVisualPage: React.FC = () => {
</div>
</Card>
))}
{!isHistorical && hasPermission('room:inspect') && (
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: 8,
marginTop: 16,
flexWrap: 'wrap',
}}
>
<Button
icon={<CheckCircleOutlined />}
onClick={() =>
setPresentOccupancyIds(
detailRoom.occupants.map((occupant: any) => occupant.occupancyId),
)
}
>
宿
</Button>
<PermissionButton
permission="room:inspect"
type="primary"
icon={<SaveOutlined />}
loading={inspectionSaving}
onClick={submitInspection}
>
</PermissionButton>
</div>
)}
</div>
) : (
<div style={{ textAlign: 'center', padding: 24, color: '#86868b' }}>

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { getInitialPresentOccupancyIds, togglePresentOccupancy } from './inspection-state';
describe('room inspection state', () => {
const occupants = [
{ occupancyId: 11, inspectionStatus: 'present' as const },
{ occupancyId: 12, inspectionStatus: 'absent' as const },
];
it('starts every occupant unchecked before the room is submitted', () => {
expect(getInitialPresentOccupancyIds(occupants, false)).toEqual([]);
});
it('restores the saved present occupants after submission', () => {
expect(getInitialPresentOccupancyIds(occupants, true)).toEqual([11]);
});
it('adds and removes a single occupant without changing the others', () => {
expect(togglePresentOccupancy([11], 12, true).sort()).toEqual([11, 12]);
expect(togglePresentOccupancy([11, 12], 11, false)).toEqual([12]);
});
});

View File

@@ -0,0 +1,25 @@
export interface InspectionOccupant {
occupancyId: number;
inspectionStatus?: 'present' | 'absent' | null;
}
export function getInitialPresentOccupancyIds(
occupants: InspectionOccupant[],
submitted: boolean,
): number[] {
if (!submitted) return [];
return occupants
.filter((occupant) => occupant.inspectionStatus === 'present')
.map((occupant) => occupant.occupancyId);
}
export function togglePresentOccupancy(
currentIds: number[],
occupancyId: number,
present: boolean,
): number[] {
const next = new Set(currentIds);
if (present) next.add(occupancyId);
else next.delete(occupancyId);
return [...next];
}

View File

@@ -5,7 +5,7 @@ import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
ignores: ['eslint.config.mjs', 'dist/**'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
@@ -27,6 +27,29 @@ export default tseslint.config(
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
'@typescript-eslint/no-unsafe-assignment': 'warn',
'@typescript-eslint/no-unsafe-call': 'warn',
'@typescript-eslint/no-unsafe-enum-comparison': 'warn',
'@typescript-eslint/no-unsafe-member-access': 'warn',
'@typescript-eslint/no-unsafe-return': 'warn',
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
'@typescript-eslint/no-base-to-string': 'warn',
'@typescript-eslint/no-require-imports': 'warn',
'@typescript-eslint/no-unnecessary-type-assertion': 'warn',
'@typescript-eslint/restrict-template-expressions': 'warn',
'no-empty': ['warn', { allowEmptyCatch: true }],
'no-useless-escape': 'warn',
},
},
{
files: ['**/*.spec.ts', '**/*.test.ts', 'test/**/*.ts'],
extends: [tseslint.configs.disableTypeChecked],
},
);

View File

@@ -14,7 +14,8 @@
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"generate:student-import": "ts-node -r tsconfig-paths/register -P tsconfig.json scripts/generate-student-import-xlsx.ts",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
"lint:fix": "npm run lint -- --fix",
"typecheck": "tsc -p tsconfig.build.json --noEmit",
"test": "jest",
"test:watch": "jest --watch",

View File

@@ -8,6 +8,16 @@ import {
randomBytes,
} from 'node:crypto';
jest.mock('node:dns', () => ({
lookup: jest.fn(
(
_hostname: string,
_options: unknown,
callback: (error: null, addresses: Array<{ address: string; family: number }>) => void,
) => callback(null, [{ address: '203.0.113.10', family: 4 }]),
),
}));
import { AiConfigService } from './ai-config.service';
import { AiConfig, AiProvider, SINGLETON_KEY } from './ai-config.entity';
@@ -105,6 +115,8 @@ describe('AiConfigService', () => {
afterEach(() => {
delete process.env.AI_CONFIG_ENCRYPTION_KEY;
delete process.env.AI_API_KEY;
delete process.env.AI_ALLOW_PRIVATE_BASE_URL;
delete process.env.NODE_ENV;
});
// ── Encryption ────────────────────────────────────────────────────────

View File

@@ -17,6 +17,8 @@ import {
BillItem,
User,
OperationLog,
RoomInspection,
RoomInspectionDetail,
Deposit,
DepositInstallment,
Classroom,
@@ -53,7 +55,12 @@ import {
import { AuthModule } from './auth/auth.module';
import { InitialSchema1784520727860 } from './migrations/1784520727860-InitialSchema';
import { AddExamManagement1784600000000 } from './migrations/1784600000000-AddExamManagement';
const allMigrations = [InitialSchema1784520727860, AddExamManagement1784600000000];
import { AddRoomInspections1784680000000 } from './migrations/1784680000000-AddRoomInspections';
const allMigrations = [
InitialSchema1784520727860,
AddExamManagement1784600000000,
AddRoomInspections1784680000000,
];
import { AuthorizationModule } from './authorization';
import { RbacModule } from './rbac/rbac.module';
import { StudentsModule } from './students/students.module';
@@ -120,6 +127,8 @@ import { IntegrationConfigModule } from './integration/config/config.module';
BillItem,
User,
OperationLog,
RoomInspection,
RoomInspectionDetail,
Deposit,
DepositInstallment,
Classroom,

View File

@@ -362,7 +362,7 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
expect(scheduleRepo.delete).not.toHaveBeenCalled();
});
it('deletes the RENTAL schedule row when the rental is cancelled', async () => {
it('deactivates the RENTAL schedule row when the rental is cancelled', async () => {
const rental = {
id: 1,
classroomId: 1,
@@ -379,7 +379,10 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
await service.cancel(1);
expect(rentalRepo.update).toHaveBeenCalledWith(1, { status: 'cancelled' });
expect(scheduleRepo.delete).toHaveBeenCalledWith({ rentalId: 1, scheduleType: 'RENTAL' });
expect(scheduleRepo.update).toHaveBeenCalledWith(
{ rentalId: 1, scheduleType: 'RENTAL' },
{ status: 'inactive' },
);
});
});
@@ -419,14 +422,14 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
});
describe('remove()', () => {
it('deletes the RENTAL schedule row and the rental', async () => {
it('archives the rental and deactivates its RENTAL schedule row', async () => {
const rental = {
id: 1,
classroomId: 1,
lesseeOrganizationId: 2,
startDate: '2026-03-01',
endDate: '2026-03-31',
status: 'cancelled',
status: 'active',
lesseeOrganization: { id: 2, name: 'Organization A' } as Organization,
} as ClassroomRental;
@@ -434,8 +437,12 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
await service.remove(1);
expect(scheduleRepo.delete).toHaveBeenCalledWith({ rentalId: 1, scheduleType: 'RENTAL' });
expect(rentalRepo.delete).toHaveBeenCalledWith(1);
expect(rentalRepo.update).toHaveBeenCalledWith(1, { status: 'cancelled' });
expect(scheduleRepo.update).toHaveBeenCalledWith(
{ rentalId: 1, scheduleType: 'RENTAL' },
{ status: 'inactive' },
);
expect(rentalRepo.delete).not.toHaveBeenCalled();
});
});
});

View File

@@ -197,7 +197,7 @@ describe('DatabaseMigrationsService — course attendance schema', () => {
],
getTable: { name: 'attendance_records', columns: [{ name: 'id' }] },
});
await bootstrapCourseAttendance(runner);
const service = await bootstrapCourseAttendance(runner);
await service.ensureCourseAttendanceSchema();
@@ -223,7 +223,7 @@ describe('DatabaseMigrationsService — course attendance schema', () => {
? { name, columns: [{ name: 'id' }] }
: { name, columns: [{ name: 'id' }, { name: 'schedule_id' }, { name: 'attendance_session_id' }] },
);
await bootstrapCourseAttendance(runner);
const service = await bootstrapCourseAttendance(runner);
await service.ensureCourseAttendanceSchema();
@@ -237,7 +237,7 @@ describe('DatabaseMigrationsService — course attendance schema', () => {
getTables: [{ name: 'attendance_records', columns: [{ name: 'id' }] }],
getTable: { name: 'attendance_records', columns: [{ name: 'id' }] },
});
await bootstrapCourseAttendance(runner);
const service = await bootstrapCourseAttendance(runner);
await service.ensureCourseAttendanceSchema();
const createSql: string = (runner.query as jest.Mock).mock.calls
@@ -497,5 +497,5 @@ async function bootstrapCourseAttendance(runner: MockRunner) {
{ provide: getDataSourceToken(), useValue: dataSource },
],
}).compile();
service = module.get(DatabaseMigrationsService);
return module.get<MigrationsPrivate & DatabaseMigrationsService>(DatabaseMigrationsService);
}

View File

@@ -9,6 +9,8 @@ export { Bill } from './bill.entity';
export { BillItem } from './bill-item.entity';
export { User } from './user.entity';
export { OperationLog } from './operation-log.entity';
export { RoomInspection } from './room-inspection.entity';
export { RoomInspectionDetail } from './room-inspection-detail.entity';
export { Deposit } from './deposit.entity';
export { DepositInstallment } from './deposit-installment.entity';
export { Classroom, ClassroomStatus } from './classroom.entity';

View File

@@ -0,0 +1,58 @@
import {
Column,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
} from 'typeorm';
import { RoomInspection } from './room-inspection.entity';
import { Occupancy } from './occupancy.entity';
import { Student } from './student.entity';
import { Bed } from './bed.entity';
export type RoomInspectionStatus = 'present' | 'absent';
@Entity('room_inspection_details')
@Unique('uq_room_inspection_details_occupancy', ['inspectionId', 'occupancyId'])
export class RoomInspectionDetail {
@PrimaryGeneratedColumn()
id: number;
@Column({ name: 'inspection_id' })
inspectionId: number;
@ManyToOne(() => RoomInspection, (inspection) => inspection.details, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'inspection_id' })
inspection: RoomInspection;
@Column({ name: 'occupancy_id' })
occupancyId: number;
@ManyToOne(() => Occupancy, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'occupancy_id' })
occupancy: Occupancy;
@Column({ name: 'student_id' })
studentId: number;
@ManyToOne(() => Student, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'student_id' })
student: Student;
@Column({ name: 'bed_id', type: 'integer', nullable: true })
bedId: number | null;
@ManyToOne(() => Bed, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'bed_id' })
bed: Bed | null;
@Column({ type: 'varchar', length: 20 })
status: RoomInspectionStatus;
@Column({ name: 'student_name_snapshot', length: 100 })
studentNameSnapshot: string;
@Column({ name: 'bed_number_snapshot', type: 'varchar', length: 20, nullable: true })
bedNumberSnapshot: string | null;
}

View File

@@ -0,0 +1,58 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';
import { Room } from './room.entity';
import { User } from './user.entity';
import { RoomInspectionDetail } from './room-inspection-detail.entity';
export type RoomInspectionSource = 'manual' | 'automatic';
@Entity('room_inspections')
@Unique('uq_room_inspections_date_room', ['inspectionDate', 'roomId'])
export class RoomInspection {
@PrimaryGeneratedColumn()
id: number;
@Column({ name: 'inspection_date', type: 'date' })
inspectionDate: string;
@Column({ name: 'room_id' })
roomId: number;
@ManyToOne(() => Room, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'room_id' })
room: Room;
@Column({ name: 'inspector_id', type: 'integer', nullable: true })
inspectorId: number | null;
@ManyToOne(() => User, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'inspector_id' })
inspector: User | null;
@Column({ name: 'inspector_name', length: 50 })
inspectorName: string;
@Column({ type: 'varchar', length: 20, default: 'manual' })
source: RoomInspectionSource;
@Column({ name: 'submitted_at', type: 'datetime' })
submittedAt: Date;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
@OneToMany(() => RoomInspectionDetail, (detail) => detail.inspection)
details: RoomInspectionDetail[];
}

View File

@@ -1,6 +1,7 @@
import { DataSource } from 'typeorm';
import { InitialSchema1784520727860 } from './migrations/1784520727860-InitialSchema';
import { AddExamManagement1784600000000 } from './migrations/1784600000000-AddExamManagement';
import { AddRoomInspections1784680000000 } from './migrations/1784680000000-AddRoomInspections';
import { config } from 'dotenv';
config();
@@ -19,7 +20,11 @@ export async function runMigrationsOnStartup(): Promise<void> {
password: process.env.DB_PASSWORD || '',
database: process.env.DB_DATABASE || 'dorm_billing',
charset: 'utf8mb4',
migrations: [InitialSchema1784520727860, AddExamManagement1784600000000],
migrations: [
InitialSchema1784520727860,
AddExamManagement1784600000000,
AddRoomInspections1784680000000,
],
});
await ds.initialize();

View File

@@ -0,0 +1,54 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddRoomInspections1784680000000 implements MigrationInterface {
name = 'AddRoomInspections1784680000000';
async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE \`room_inspections\` (
\`id\` int NOT NULL AUTO_INCREMENT,
\`inspection_date\` date NOT NULL,
\`room_id\` int NOT NULL,
\`inspector_id\` int NULL,
\`inspector_name\` varchar(50) NOT NULL,
\`source\` varchar(20) NOT NULL DEFAULT 'manual',
\`submitted_at\` datetime NOT NULL,
\`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
\`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
UNIQUE INDEX \`uq_room_inspections_date_room\` (\`inspection_date\`, \`room_id\`),
INDEX \`idx_room_inspections_inspector_id\` (\`inspector_id\`),
PRIMARY KEY (\`id\`)
) ENGINE=InnoDB`);
await queryRunner.query(`CREATE TABLE \`room_inspection_details\` (
\`id\` int NOT NULL AUTO_INCREMENT,
\`inspection_id\` int NOT NULL,
\`occupancy_id\` int NOT NULL,
\`student_id\` int NOT NULL,
\`bed_id\` int NULL,
\`status\` varchar(20) NOT NULL,
\`student_name_snapshot\` varchar(100) NOT NULL,
\`bed_number_snapshot\` varchar(20) NULL,
UNIQUE INDEX \`uq_room_inspection_details_occupancy\` (\`inspection_id\`, \`occupancy_id\`),
INDEX \`idx_room_inspection_details_occupancy_id\` (\`occupancy_id\`),
INDEX \`idx_room_inspection_details_student_id\` (\`student_id\`),
INDEX \`idx_room_inspection_details_bed_id\` (\`bed_id\`),
PRIMARY KEY (\`id\`)
) ENGINE=InnoDB`);
await queryRunner.query('ALTER TABLE `room_inspections` ADD CONSTRAINT `fk_room_inspections_room` FOREIGN KEY (`room_id`) REFERENCES `rooms`(`id`) ON DELETE CASCADE');
await queryRunner.query('ALTER TABLE `room_inspections` ADD CONSTRAINT `fk_room_inspections_inspector` FOREIGN KEY (`inspector_id`) REFERENCES `users`(`id`) ON DELETE SET NULL');
await queryRunner.query('ALTER TABLE `room_inspection_details` ADD CONSTRAINT `fk_room_inspection_details_inspection` FOREIGN KEY (`inspection_id`) REFERENCES `room_inspections`(`id`) ON DELETE CASCADE');
await queryRunner.query('ALTER TABLE `room_inspection_details` ADD CONSTRAINT `fk_room_inspection_details_occupancy` FOREIGN KEY (`occupancy_id`) REFERENCES `occupancies`(`id`) ON DELETE RESTRICT');
await queryRunner.query('ALTER TABLE `room_inspection_details` ADD CONSTRAINT `fk_room_inspection_details_student` FOREIGN KEY (`student_id`) REFERENCES `students`(`id`) ON DELETE RESTRICT');
await queryRunner.query('ALTER TABLE `room_inspection_details` ADD CONSTRAINT `fk_room_inspection_details_bed` FOREIGN KEY (`bed_id`) REFERENCES `beds`(`id`) ON DELETE SET NULL');
}
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE `room_inspection_details` DROP FOREIGN KEY `fk_room_inspection_details_bed`');
await queryRunner.query('ALTER TABLE `room_inspection_details` DROP FOREIGN KEY `fk_room_inspection_details_student`');
await queryRunner.query('ALTER TABLE `room_inspection_details` DROP FOREIGN KEY `fk_room_inspection_details_occupancy`');
await queryRunner.query('ALTER TABLE `room_inspection_details` DROP FOREIGN KEY `fk_room_inspection_details_inspection`');
await queryRunner.query('ALTER TABLE `room_inspections` DROP FOREIGN KEY `fk_room_inspections_inspector`');
await queryRunner.query('ALTER TABLE `room_inspections` DROP FOREIGN KEY `fk_room_inspections_room`');
await queryRunner.query('DROP TABLE `room_inspection_details`');
await queryRunner.query('DROP TABLE `room_inspections`');
}
}

View File

@@ -44,6 +44,7 @@ describe('preset role permissions', () => {
);
expect(accommodation.extras).toContain('student:basic-view');
expect(accommodation.extras).not.toContain('organization:view');
expect(accommodation.groups).toContain('room');
});
it('keeps classroom rental operations separate from accommodation operations', () => {

View File

@@ -28,6 +28,7 @@ const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> =
{ code: 'student:export', name: '导出学生', group: 'student' },
{ code: 'exam:view', name: '查看和录入考试成绩', group: 'exam' },
{ code: 'room:view', name: '查看宿舍', group: 'room' },
{ code: 'room:inspect', name: '宿舍查寝', group: 'room' },
{ code: 'room:create', name: '新增宿舍', group: 'room' },
{ code: 'room:edit', name: '编辑宿舍', group: 'room' },
{ code: 'room:delete', name: '归档宿舍', group: 'room' },

View File

@@ -0,0 +1,8 @@
import { ArrayUnique, IsArray, IsInt } from 'class-validator';
export class UpdateRoomInspectionDto {
@IsArray()
@ArrayUnique()
@IsInt({ each: true })
presentOccupancyIds: number[];
}

View File

@@ -0,0 +1,174 @@
import { BadRequestException } from '@nestjs/common';
import { DataSource, EntityManager, Repository } from 'typeorm';
import { Occupancy } from '../entities/occupancy.entity';
import { Room } from '../entities/room.entity';
import { RoomInspection } from '../entities/room-inspection.entity';
import { RoomInspectionDetail } from '../entities/room-inspection-detail.entity';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { RoomInspectionsService } from './room-inspections.service';
describe('RoomInspectionsService', () => {
const today = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date());
const createService = (options?: {
existingInspection?: Partial<RoomInspection> | null;
existingInspections?: Partial<RoomInspection>[];
occupancies?: Occupancy[];
}) => {
const inspectionRepo = {
findOne: jest.fn().mockResolvedValue(options?.existingInspection ?? null),
create: jest.fn((value) => value),
save: jest.fn(async (value) => ({ id: 91, ...value })),
find: jest.fn().mockResolvedValue(options?.existingInspections ?? []),
} as unknown as Repository<RoomInspection>;
const detailRepo = {
create: jest.fn((value) => value),
save: jest.fn(async (value) => value),
delete: jest.fn().mockResolvedValue(undefined),
} as unknown as Repository<RoomInspectionDetail>;
const roomRepo = {
findOne: jest.fn().mockResolvedValue({ id: 5, roomNumber: '4-102' }),
} as unknown as Repository<Room>;
const occupancies = options?.occupancies ?? ([
{
id: 11,
roomId: 5,
studentId: 21,
bedId: 31,
student: { name: '张三' },
bed: { bedNumber: '1号床' },
},
{
id: 12,
roomId: 5,
studentId: 22,
bedId: 32,
student: { name: '李四' },
bed: { bedNumber: '2号床' },
},
] as Occupancy[]);
const occupancyRepo = {
find: jest.fn().mockResolvedValue(occupancies),
} as unknown as Repository<Occupancy>;
const roomQuery = {
where: jest.fn().mockReturnThis(),
getOne: jest.fn().mockResolvedValue({ id: 5, roomNumber: '4-102', status: 'full' }),
setLock: jest.fn().mockReturnThis(),
};
const manager = {
createQueryBuilder: jest.fn().mockReturnValue(roomQuery),
getRepository: jest.fn((entity) => {
if (entity === RoomInspection) return inspectionRepo;
if (entity === RoomInspectionDetail) return detailRepo;
if (entity === Occupancy) return occupancyRepo;
throw new Error(`unexpected repository ${String(entity)}`);
}),
} as unknown as EntityManager;
const dataSource = {
options: { type: 'better-sqlite3' },
manager,
transaction: jest.fn(async (callback) => callback(manager)),
} as unknown as DataSource;
const logs = { log: jest.fn().mockResolvedValue(undefined) } as unknown as OperationLogsService;
const service = new RoomInspectionsService(
inspectionRepo,
detailRepo,
roomRepo,
occupancyRepo,
dataSource,
logs,
);
return { service, inspectionRepo, detailRepo, occupancyRepo, roomRepo, logs };
};
it('creates present and absent details in one transaction', async () => {
const { service, detailRepo } = createService();
const result = await service.submit(5, today, [11], { id: 7, username: 'teacher' });
expect(detailRepo.delete).toHaveBeenCalledWith({ inspectionId: 91 });
expect(detailRepo.save).toHaveBeenCalledWith([
expect.objectContaining({ occupancyId: 11, status: 'present', studentNameSnapshot: '张三' }),
expect.objectContaining({ occupancyId: 12, status: 'absent', studentNameSnapshot: '李四' }),
]);
expect(result.presentNames).toEqual(['张三']);
expect(result.absentNames).toEqual(['李四']);
});
it('replaces details when the same room is submitted again today', async () => {
const { service, inspectionRepo, detailRepo } = createService({
existingInspection: { id: 91, roomId: 5, inspectionDate: today },
});
const result = await service.submit(5, today, [11, 12], { id: 8, username: 'reviewer' });
expect(result.isUpdate).toBe(true);
expect(inspectionRepo.save).toHaveBeenCalledWith(
expect.objectContaining({ id: 91, inspectorId: 8, source: 'manual' }),
);
expect(detailRepo.delete).toHaveBeenCalledWith({ inspectionId: 91 });
});
it('rejects historical and future dates', async () => {
const { service } = createService();
await expect(service.submit('5' as never, '2000-01-01', [], {})).rejects.toThrow(
new BadRequestException('历史日期的查寝记录不可更改'),
);
await expect(service.submit(5, '2999-01-01', [], {})).rejects.toThrow(
new BadRequestException('不能提前提交未来日期的查寝记录'),
);
});
it('rejects occupancy ids outside the room and date snapshot', async () => {
const { service } = createService();
await expect(service.submit(5, today, [999], { id: 7, username: 'teacher' })).rejects.toThrow(
'存在不属于该宿舍当日住户的入住记录: 999',
);
});
it('attributes automatic absences to the last manual inspector', async () => {
const { service, inspectionRepo, logs } = createService();
(inspectionRepo.findOne as jest.Mock).mockResolvedValueOnce({
inspectorId: 8,
inspectorName: 'last-teacher',
source: 'manual',
});
const created = await service.settleDate('2026-07-21');
expect(created).toBe(1);
expect(inspectionRepo.save).toHaveBeenCalledWith(
expect.objectContaining({
inspectorId: 8,
inspectorName: 'last-teacher',
source: 'automatic',
}),
);
expect(logs.log).toHaveBeenCalledWith(
expect.objectContaining({ username: 'last-teacher', action: '自动补记缺勤' }),
);
});
it('uses the system identity when nobody inspected any room that day', async () => {
const { service, inspectionRepo, logs } = createService();
(inspectionRepo.findOne as jest.Mock).mockResolvedValueOnce(null);
await service.settleDate('2026-07-21');
expect(inspectionRepo.save).toHaveBeenCalledWith(
expect.objectContaining({
inspectorId: null,
inspectorName: '系统自动判定',
source: 'automatic',
}),
);
expect(logs.log).toHaveBeenCalledWith(
expect.objectContaining({ username: '系统自动判定' }),
);
});
});

View File

@@ -0,0 +1,255 @@
import { BadRequestException, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, EntityManager, IsNull, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
import { Bed } from '../entities/bed.entity';
import { Occupancy } from '../entities/occupancy.entity';
import { Room } from '../entities/room.entity';
import { RoomInspection } from '../entities/room-inspection.entity';
import { RoomInspectionDetail } from '../entities/room-inspection-detail.entity';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
interface InspectorIdentity {
id?: number;
username?: string;
}
@Injectable()
export class RoomInspectionsService implements OnApplicationBootstrap {
private readonly logger = new Logger(RoomInspectionsService.name);
private settling = false;
constructor(
@InjectRepository(RoomInspection)
private readonly inspectionRepo: Repository<RoomInspection>,
@InjectRepository(RoomInspectionDetail)
private readonly detailRepo: Repository<RoomInspectionDetail>,
@InjectRepository(Room)
private readonly roomRepo: Repository<Room>,
@InjectRepository(Occupancy)
private readonly occupancyRepo: Repository<Occupancy>,
private readonly dataSource: DataSource,
private readonly operationLogs: OperationLogsService,
) {}
async onApplicationBootstrap(): Promise<void> {
await this.settlePreviousDay().catch((error) => {
this.logger.error('补记昨日宿舍查寝失败', error instanceof Error ? error.stack : String(error));
});
}
@Cron('5 0 * * *', { timeZone: 'Asia/Shanghai' })
async settlePreviousDay(now = new Date()): Promise<void> {
if (this.settling) return;
this.settling = true;
try {
const today = this.getChinaDate(now);
const targetDate = this.shiftDate(today, -1);
await this.settleDate(targetDate);
} finally {
this.settling = false;
}
}
async submit(
roomId: number,
inspectionDate: string,
presentOccupancyIds: number[],
inspector: InspectorIdentity,
) {
this.assertToday(inspectionDate);
const uniquePresentIds = [...new Set(presentOccupancyIds)];
return this.dataSource.transaction(async (manager) => {
const room = await this.lockRoom(manager, roomId, false);
const occupancies = await this.findOccupanciesForDate(manager, roomId, inspectionDate);
const allowedIds = new Set(occupancies.map((occupancy) => occupancy.id));
const invalidIds = uniquePresentIds.filter((id) => !allowedIds.has(id));
if (invalidIds.length > 0) {
throw new BadRequestException(`存在不属于该宿舍当日住户的入住记录: ${invalidIds.join(', ')}`);
}
const inspectionRepo = manager.getRepository(RoomInspection);
const detailRepo = manager.getRepository(RoomInspectionDetail);
let inspection = await inspectionRepo.findOne({
where: { roomId, inspectionDate },
});
const isUpdate = !!inspection;
if (!inspection) {
inspection = inspectionRepo.create({ roomId, inspectionDate });
}
inspection.inspectorId = inspector.id ?? null;
inspection.inspectorName = inspector.username || '未知用户';
inspection.source = 'manual';
inspection.submittedAt = new Date();
inspection = await inspectionRepo.save(inspection);
await detailRepo.delete({ inspectionId: inspection.id });
const presentSet = new Set(uniquePresentIds);
const details = occupancies.map((occupancy) =>
detailRepo.create({
inspectionId: inspection.id,
occupancyId: occupancy.id,
studentId: occupancy.studentId,
bedId: occupancy.bedId ?? null,
status: presentSet.has(occupancy.id) ? 'present' : 'absent',
studentNameSnapshot: occupancy.student?.name || '未知学生',
bedNumberSnapshot: occupancy.bed?.bedNumber || null,
}),
);
if (details.length > 0) await detailRepo.save(details);
return {
inspection: { ...inspection, details },
roomNumber: room.roomNumber,
isUpdate,
presentNames: details
.filter((detail) => detail.status === 'present')
.map((detail) => detail.studentNameSnapshot),
absentNames: details
.filter((detail) => detail.status === 'absent')
.map((detail) => detail.studentNameSnapshot),
};
});
}
async getByRoomsAndDate(roomIds: number[], inspectionDate: string) {
if (roomIds.length === 0) return new Map<number, RoomInspection>();
const inspections = await this.inspectionRepo
.createQueryBuilder('inspection')
.leftJoinAndSelect('inspection.details', 'detail')
.where('inspection.roomId IN (:...roomIds)', { roomIds })
.andWhere('inspection.inspectionDate = :inspectionDate', { inspectionDate })
.getMany();
return new Map(inspections.map((inspection) => [inspection.roomId, inspection]));
}
async settleDate(inspectionDate: string): Promise<number> {
const existing = await this.inspectionRepo.find({ where: { inspectionDate } });
const existingRoomIds = new Set(existing.map((inspection) => inspection.roomId));
const occupancies = await this.findAllOccupanciesForDate(this.dataSource.manager, inspectionDate);
const byRoom = new Map<number, Occupancy[]>();
for (const occupancy of occupancies) {
if (existingRoomIds.has(occupancy.roomId)) continue;
const roomOccupancies = byRoom.get(occupancy.roomId) ?? [];
roomOccupancies.push(occupancy);
byRoom.set(occupancy.roomId, roomOccupancies);
}
if (byRoom.size === 0) return 0;
const lastManualInspection = await this.inspectionRepo.findOne({
where: { inspectionDate, source: 'manual' },
order: { submittedAt: 'DESC' },
});
const inspectorId = lastManualInspection?.inspectorId ?? null;
const inspectorName = lastManualInspection?.inspectorName || '系统自动判定';
let created = 0;
for (const [roomId, roomOccupancies] of byRoom) {
const result = await this.dataSource.transaction(async (manager) => {
await this.lockRoom(manager, roomId, true);
const inspectionRepo = manager.getRepository(RoomInspection);
const detailRepo = manager.getRepository(RoomInspectionDetail);
const duplicate = await inspectionRepo.findOne({ where: { roomId, inspectionDate } });
if (duplicate) return null;
const inspection = await inspectionRepo.save(
inspectionRepo.create({
inspectionDate,
roomId,
inspectorId,
inspectorName,
source: 'automatic',
submittedAt: new Date(),
}),
);
const details = roomOccupancies.map((occupancy) =>
detailRepo.create({
inspectionId: inspection.id,
occupancyId: occupancy.id,
studentId: occupancy.studentId,
bedId: occupancy.bedId ?? null,
status: 'absent',
studentNameSnapshot: occupancy.student?.name || '未知学生',
bedNumberSnapshot: occupancy.bed?.bedNumber || null,
}),
);
await detailRepo.save(details);
return { inspection, details };
});
if (!result) continue;
created++;
const room = await this.roomRepo.findOne({ where: { id: roomId } });
await this.operationLogs.log({
userId: inspectorId ?? undefined,
username: inspectorName,
module: '宿舍查寝',
action: '自动补记缺勤',
targetId: roomId,
targetType: 'room',
detail: `查寝日期: ${inspectionDate}, 宿舍: ${room?.roomNumber || roomId}, 缺勤: ${result.details.map((detail) => detail.studentNameSnapshot).join('、') || '无'}`,
});
}
return created;
}
private async lockRoom(
manager: EntityManager,
roomId: number,
allowArchived: boolean,
): Promise<Room> {
let query = manager.createQueryBuilder(Room, 'room').where('room.id = :roomId', { roomId });
if (['mysql', 'mariadb', 'postgres', 'cockroachdb'].includes(this.dataSource.options.type)) {
query = query.setLock('pessimistic_write');
}
const room = await query.getOne();
if (!room) throw new BadRequestException('宿舍不存在');
if (!allowArchived && room.status === 'archived') {
throw new BadRequestException('已归档宿舍不能查寝');
}
return room;
}
private findOccupanciesForDate(manager: EntityManager, roomId: number, date: string) {
return manager.getRepository(Occupancy).find({
where: [
{ roomId, checkInDate: LessThanOrEqual(date), checkOutDate: IsNull() },
{ roomId, checkInDate: LessThanOrEqual(date), checkOutDate: MoreThanOrEqual(date) },
],
relations: ['student', 'bed'],
order: { id: 'ASC' },
});
}
private findAllOccupanciesForDate(manager: EntityManager, date: string) {
return manager.getRepository(Occupancy).find({
where: [
{ checkInDate: LessThanOrEqual(date), checkOutDate: IsNull() },
{ checkInDate: LessThanOrEqual(date), checkOutDate: MoreThanOrEqual(date) },
],
relations: ['student', 'bed'],
order: { roomId: 'ASC', id: 'ASC' },
});
}
private assertToday(date: string): void {
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new BadRequestException('查寝日期格式错误');
const today = this.getChinaDate(new Date());
if (date < today) throw new BadRequestException('历史日期的查寝记录不可更改');
if (date > today) throw new BadRequestException('不能提前提交未来日期的查寝记录');
}
private getChinaDate(now: Date): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(now);
}
private shiftDate(date: string, days: number): string {
const shifted = new Date(`${date}T12:00:00Z`);
shifted.setUTCDate(shifted.getUTCDate() + days);
return shifted.toISOString().slice(0, 10);
}
}

View File

@@ -7,6 +7,7 @@ import { RoomExpense } from '../entities/room-expense.entity';
import { RoomsService } from './rooms.service';
import { RoomsController } from './rooms.controller';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { PERMISSION_KEY } from '../auth/decorators/permission.decorator';
// ── Controller test mocks ──────────────────────────────────────────────
@@ -90,6 +91,14 @@ describe('RoomsService — parseRoomNumber boundary conditions', () => {
});
});
describe('RoomsController — inspection permission boundary', () => {
it('requires the dedicated room:inspect permission', () => {
expect(Reflect.getMetadata(PERMISSION_KEY, RoomsController.prototype.updateInspection)).toEqual([
'room:inspect',
]);
});
});
// ── Service: batchImport boundary conditions ───────────────────────────
describe('RoomsService — batchImport boundary conditions', () => {
@@ -123,6 +132,7 @@ describe('RoomsService — batchImport boundary conditions', () => {
bedRepo,
lockerRepo,
dataSource,
{ getByRoomsAndDate: jest.fn().mockResolvedValue(new Map()) } as never,
);
return { service, roomRepo, bedRepo };
@@ -212,7 +222,11 @@ describe('RoomsController — boundary conditions', () => {
cb(dataRow, 2);
});
const controller = new RoomsController(mockRoomsService, mockLogService);
const controller = new RoomsController(
mockRoomsService,
mockLogService,
{ submit: jest.fn() } as never,
);
const file = { buffer: Buffer.from('fake') } as Express.Multer.File;
const req = { user: { id: 1, username: 'tester' } };

View File

@@ -19,6 +19,8 @@ import { RoomsService } from './rooms.service';
import { CreateRoomDto, UpdateRoomDto } from './dto/room.dto';
import { CreateBedDto, UpdateBedDto, BatchCreateBedDto } from './dto/bed.dto';
import { CreateLockerDto, UpdateLockerDto, BatchCreateLockerDto } from './dto/locker.dto';
import { UpdateRoomInspectionDto } from './dto/room-inspection.dto';
import { RoomInspectionsService } from './room-inspections.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils';
@@ -31,6 +33,7 @@ export class RoomsController {
constructor(
private service: RoomsService,
private logService: OperationLogsService,
private inspectionsService: RoomInspectionsService,
) {}
@Get()
@@ -54,6 +57,35 @@ export class RoomsController {
return this.service.getRoomVisual(asOf);
}
@Put(':roomId/inspections/:date')
@RequirePermission('room:inspect')
async updateInspection(
@Param('roomId') roomId: string,
@Param('date') date: string,
@Body() dto: UpdateRoomInspectionDto,
@Request() req: any,
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.inspectionsService.submit(
+roomId,
date,
dto.presentOccupancyIds,
{ id: req.user?.id, username: req.user?.username },
);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '宿舍查寝',
action: result.isUpdate ? '修改查寝记录' : '提交查寝记录',
targetId: +roomId,
targetType: 'room',
detail: `查寝日期: ${date}, 宿舍: ${result.roomNumber}, 在寝: ${result.presentNames.join('、') || '无'}, 缺勤: ${result.absentNames.join('、') || '无'}`,
ipAddress,
userAgent,
});
return result.inspection;
}
@Get('template')
@RequirePermission('room:view')
async downloadTemplate(@Res() res: Response) {

View File

@@ -5,14 +5,28 @@ import { Occupancy } from '../entities/occupancy.entity';
import { RoomExpense } from '../entities/room-expense.entity';
import { Bed } from '../entities/bed.entity';
import { Locker } from '../entities/locker.entity';
import { RoomInspection } from '../entities/room-inspection.entity';
import { RoomInspectionDetail } from '../entities/room-inspection-detail.entity';
import { RoomsService } from './rooms.service';
import { RoomsController } from './rooms.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
import { RoomInspectionsService } from './room-inspections.service';
@Module({
imports: [TypeOrmModule.forFeature([Room, Occupancy, RoomExpense, Bed, Locker]), OperationLogsModule],
imports: [
TypeOrmModule.forFeature([
Room,
Occupancy,
RoomExpense,
Bed,
Locker,
RoomInspection,
RoomInspectionDetail,
]),
OperationLogsModule,
],
controllers: [RoomsController],
providers: [RoomsService],
exports: [RoomsService],
providers: [RoomsService, RoomInspectionsService],
exports: [RoomsService, RoomInspectionsService],
})
export class RoomsModule {}

View File

@@ -54,6 +54,7 @@ describe('RoomsService — capacity consistency', () => {
bedRepo,
{} as Repository<Locker>,
dataSource,
{ getByRoomsAndDate: jest.fn().mockResolvedValue(new Map()) } as never,
);
return { service, roomRepo, bedRepo, occupancyRepo };

View File

@@ -19,6 +19,7 @@ import { Locker } from '../entities/locker.entity';
import { CreateRoomDto, UpdateRoomDto } from './dto/room.dto';
import { CreateBedDto, UpdateBedDto, BatchCreateBedDto } from './dto/bed.dto';
import { CreateLockerDto, UpdateLockerDto, BatchCreateLockerDto } from './dto/locker.dto';
import { RoomInspectionsService } from './room-inspections.service';
@Injectable()
export class RoomsService {
@@ -29,6 +30,7 @@ export class RoomsService {
@InjectRepository(Bed) private bedRepo: Repository<Bed>,
@InjectRepository(Locker) private lockerRepo: Repository<Locker>,
private dataSource: DataSource,
private readonly inspectionsService: RoomInspectionsService,
) {}
/**
@@ -235,7 +237,7 @@ export class RoomsService {
async getRoomVisual(asOf?: string) {
// asOf 为空 = 实时(今天)。带 asOf = 还原该日期结束时的历史入住快照。
const isHistorical = !!asOf;
const targetDate = asOf || new Date().toISOString().slice(0, 10);
const targetDate = asOf || this.getChinaDate(new Date());
// 实时视图排除已归档房间;历史视图不排除——当时有人住的房间即使现在已归档也应显示。
const rooms = await this.repo.find({
@@ -244,13 +246,11 @@ export class RoomsService {
});
const occupancies = await this.occRepo.find({
where: isHistorical
? [
{ checkInDate: LessThanOrEqual(targetDate), checkOutDate: IsNull() },
{ checkInDate: LessThanOrEqual(targetDate), checkOutDate: MoreThanOrEqual(targetDate) },
]
: { checkOutDate: IsNull() },
relations: ['student', 'student.organization', 'responsibleOrganization'],
where: [
{ checkInDate: LessThanOrEqual(targetDate), checkOutDate: IsNull() },
{ checkInDate: LessThanOrEqual(targetDate), checkOutDate: MoreThanOrEqual(targetDate) },
],
relations: ['student', 'student.organization', 'responsibleOrganization', 'bed'],
order: { checkInDate: 'ASC' },
});
@@ -264,7 +264,10 @@ export class RoomsService {
const days = Math.max(1, Math.ceil((refTime - checkIn.getTime()) / (1000 * 60 * 60 * 24)));
occMap.get(occ.roomId)!.push({
studentId: occ.studentId,
occupancyId: occ.id,
studentName: occ.student?.name || '未知',
bedId: occ.bedId ?? null,
bedNumber: occ.bed?.bedNumber || null,
checkInDate: occ.checkInDate,
billingStartDate: occ.billingStartDate,
days,
@@ -296,10 +299,19 @@ export class RoomsService {
if (bed.status === 'occupied') entry.occupied++;
}
const inspectionMap = await this.inspectionsService.getByRoomsAndDate(
visibleRooms.map((room) => room.id),
targetDate,
);
return {
buildings,
rooms: visibleRooms.map((room) => {
const occ = occMap.get(room.id) || [];
const inspection = inspectionMap.get(room.id);
const inspectionByOccupancyId = new Map(
(inspection?.details || []).map((detail) => [detail.occupancyId, detail.status]),
);
const orgs = [...new Set(occ.map((o: any) => o.organization).filter(Boolean))];
let orgLabel: string | null = null;
if (orgs.length > 0 && occ.length > 0) {
@@ -322,7 +334,19 @@ export class RoomsService {
currentCount: occ.length,
totalBeds: bedMap.get(room.id)?.total ?? 0,
occupiedBeds: bedMap.get(room.id)?.occupied ?? 0,
occupants: occ,
occupants: occ.map((occupant) => ({
...occupant,
inspectionStatus: inspectionByOccupancyId.get(occupant.occupancyId) || null,
})),
inspection: inspection
? {
submitted: true,
inspectorId: inspection.inspectorId,
inspectorName: inspection.inspectorName,
source: inspection.source,
submittedAt: inspection.submittedAt,
}
: { submitted: false },
orgLabel,
organizationColor,
organizationIds,
@@ -346,6 +370,15 @@ export class RoomsService {
};
}
private getChinaDate(now: Date): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(now);
}
async batchImport(
rows: {
roomNumber: string;

View File

@@ -371,14 +371,9 @@ describe('ScheduleSyncService — dedup', () => {
seen.add(k);
}
// At least one item exists for 07-08 (proving overlap was handled)
// work_date is epoch ms at 00:00:00+08:00; convert back to date string
const fmtDate = (epochMs: number) => {
const d = new Date(epochMs);
return new Date(d.getTime() - d.getTimezoneOffset() * 60000)
.toISOString().slice(0, 10);
};
const july8Items = batchItems.filter((i) => fmtDate(i.work_date) === '2026-07-08');
// Compare the exact Beijing-midnight epoch so this assertion is independent of host timezone.
const july8Epoch = new Date('2026-07-08T00:00:00+08:00').getTime();
const july8Items = batchItems.filter((item) => item.work_date === july8Epoch);
expect(july8Items.length).toBe(1);
});
});