增加宿舍查寝功能 #34
@@ -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
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
25
apps/admin/src/pages/RoomVisual/inspection-state.ts
Normal file
25
apps/admin/src/pages/RoomVisual/inspection-state.ts
Normal 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];
|
||||
}
|
||||
@@ -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],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
58
apps/server/src/entities/room-inspection-detail.entity.ts
Normal file
58
apps/server/src/entities/room-inspection-detail.entity.ts
Normal 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;
|
||||
}
|
||||
58
apps/server/src/entities/room-inspection.entity.ts
Normal file
58
apps/server/src/entities/room-inspection.entity.ts
Normal 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[];
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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`');
|
||||
}
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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' },
|
||||
|
||||
8
apps/server/src/rooms/dto/room-inspection.dto.ts
Normal file
8
apps/server/src/rooms/dto/room-inspection.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { ArrayUnique, IsArray, IsInt } from 'class-validator';
|
||||
|
||||
export class UpdateRoomInspectionDto {
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@IsInt({ each: true })
|
||||
presentOccupancyIds: number[];
|
||||
}
|
||||
174
apps/server/src/rooms/room-inspections.service.spec.ts
Normal file
174
apps/server/src/rooms/room-inspections.service.spec.ts
Normal 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: '系统自动判定' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
255
apps/server/src/rooms/room-inspections.service.ts
Normal file
255
apps/server/src/rooms/room-inspections.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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' } };
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user