Merge pull request '增加宿舍查寝功能' (#34) from xiongyuxing/gongxue-base:main into main
This commit is contained in:
@@ -17,7 +17,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
check:
|
check:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: node:20.20.2-bookworm
|
container: node:22.22.0-bookworm
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -33,7 +33,7 @@ jobs:
|
|||||||
- name: Lint
|
- name: Lint
|
||||||
run: |
|
run: |
|
||||||
npm run lint -w @gongxue/admin
|
npm run lint -w @gongxue/admin
|
||||||
npm run lint -w @gongxue/server -- --no-fix
|
npm run lint -w @gongxue/server -- --quiet
|
||||||
|
|
||||||
- name: Type check
|
- name: Type check
|
||||||
run: npm run typecheck
|
run: npm run typecheck
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
DatePicker,
|
DatePicker,
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
|
Switch,
|
||||||
|
Space,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
HomeOutlined,
|
HomeOutlined,
|
||||||
@@ -21,10 +23,15 @@ import {
|
|||||||
BankOutlined,
|
BankOutlined,
|
||||||
HistoryOutlined,
|
HistoryOutlined,
|
||||||
ShopOutlined,
|
ShopOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
SaveOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import dayjs, { Dayjs } from 'dayjs';
|
import dayjs, { Dayjs } from 'dayjs';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { message } from '../../ui/app-message';
|
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 {
|
function getCardStyle(room: any): React.CSSProperties {
|
||||||
let base: React.CSSProperties;
|
let base: React.CSSProperties;
|
||||||
@@ -84,6 +91,9 @@ const RoomVisualPage: React.FC = () => {
|
|||||||
const [selectedOrganization, setSelectedOrganization] = useState<number | 'all'>('all');
|
const [selectedOrganization, setSelectedOrganization] = useState<number | 'all'>('all');
|
||||||
const [detailRoom, setDetailRoom] = useState<any>(null);
|
const [detailRoom, setDetailRoom] = useState<any>(null);
|
||||||
const [asOf, setAsOf] = useState<Dayjs | null>(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');
|
const isHistorical = !!asOf && !asOf.isSame(dayjs(), 'day');
|
||||||
|
|
||||||
@@ -104,6 +114,42 @@ const RoomVisualPage: React.FC = () => {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [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' }} />;
|
if (!data) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
|
||||||
|
|
||||||
const rooms = data.rooms.filter((r: any) => {
|
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>}
|
{room.occupants.length > 4 && <Tag>+{room.occupants.length - 4}</Tag>}
|
||||||
</div>
|
</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>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
))}
|
))}
|
||||||
@@ -388,9 +445,26 @@ const RoomVisualPage: React.FC = () => {
|
|||||||
<div style={{ marginBottom: 16 }}>{getStatusLabel(detailRoom)}</div>
|
<div style={{ marginBottom: 16 }}>{getStatusLabel(detailRoom)}</div>
|
||||||
{detailRoom.occupants.length > 0 ? (
|
{detailRoom.occupants.length > 0 ? (
|
||||||
<div>
|
<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) => (
|
{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
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -407,10 +481,44 @@ const RoomVisualPage: React.FC = () => {
|
|||||||
</Tag>
|
</Tag>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
<div style={{ color: '#86868b', fontSize: 12, marginTop: 4 }}>
|
<div style={{ color: '#86868b', fontSize: 12, marginTop: 4 }}>
|
||||||
<CalendarOutlined style={{ marginRight: 4 }} />
|
<CalendarOutlined style={{ marginRight: 4 }} />
|
||||||
|
床位:{o.bedNumber || '未分配'} |{' '}
|
||||||
入住:{o.checkInDate} | 计费起:{o.billingStartDate}
|
入住:{o.checkInDate} | 计费起:{o.billingStartDate}
|
||||||
{o.supervisor && (
|
{o.supervisor && (
|
||||||
<span style={{ marginLeft: 8 }}>负责人:{o.supervisor}</span>
|
<span style={{ marginLeft: 8 }}>负责人:{o.supervisor}</span>
|
||||||
@@ -418,6 +526,37 @@ const RoomVisualPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</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>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ textAlign: 'center', padding: 24, color: '#86868b' }}>
|
<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(
|
export default tseslint.config(
|
||||||
{
|
{
|
||||||
ignores: ['eslint.config.mjs'],
|
ignores: ['eslint.config.mjs', 'dist/**'],
|
||||||
},
|
},
|
||||||
eslint.configs.recommended,
|
eslint.configs.recommended,
|
||||||
...tseslint.configs.recommendedTypeChecked,
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
@@ -27,6 +27,29 @@ export default tseslint.config(
|
|||||||
'@typescript-eslint/no-explicit-any': 'off',
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
'@typescript-eslint/no-floating-promises': 'warn',
|
'@typescript-eslint/no-floating-promises': 'warn',
|
||||||
'@typescript-eslint/no-unsafe-argument': '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:debug": "nest start --debug --watch",
|
||||||
"start:prod": "node dist/main",
|
"start:prod": "node dist/main",
|
||||||
"generate:student-import": "ts-node -r tsconfig-paths/register -P tsconfig.json scripts/generate-student-import-xlsx.ts",
|
"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",
|
"typecheck": "tsc -p tsconfig.build.json --noEmit",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
|
|||||||
@@ -8,6 +8,16 @@ import {
|
|||||||
randomBytes,
|
randomBytes,
|
||||||
} from 'node:crypto';
|
} 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 { AiConfigService } from './ai-config.service';
|
||||||
import { AiConfig, AiProvider, SINGLETON_KEY } from './ai-config.entity';
|
import { AiConfig, AiProvider, SINGLETON_KEY } from './ai-config.entity';
|
||||||
|
|
||||||
@@ -105,6 +115,8 @@ describe('AiConfigService', () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
delete process.env.AI_CONFIG_ENCRYPTION_KEY;
|
delete process.env.AI_CONFIG_ENCRYPTION_KEY;
|
||||||
delete process.env.AI_API_KEY;
|
delete process.env.AI_API_KEY;
|
||||||
|
delete process.env.AI_ALLOW_PRIVATE_BASE_URL;
|
||||||
|
delete process.env.NODE_ENV;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Encryption ────────────────────────────────────────────────────────
|
// ── Encryption ────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
BillItem,
|
BillItem,
|
||||||
User,
|
User,
|
||||||
OperationLog,
|
OperationLog,
|
||||||
|
RoomInspection,
|
||||||
|
RoomInspectionDetail,
|
||||||
Deposit,
|
Deposit,
|
||||||
DepositInstallment,
|
DepositInstallment,
|
||||||
Classroom,
|
Classroom,
|
||||||
@@ -53,7 +55,12 @@ import {
|
|||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { InitialSchema1784520727860 } from './migrations/1784520727860-InitialSchema';
|
import { InitialSchema1784520727860 } from './migrations/1784520727860-InitialSchema';
|
||||||
import { AddExamManagement1784600000000 } from './migrations/1784600000000-AddExamManagement';
|
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 { AuthorizationModule } from './authorization';
|
||||||
import { RbacModule } from './rbac/rbac.module';
|
import { RbacModule } from './rbac/rbac.module';
|
||||||
import { StudentsModule } from './students/students.module';
|
import { StudentsModule } from './students/students.module';
|
||||||
@@ -120,6 +127,8 @@ import { IntegrationConfigModule } from './integration/config/config.module';
|
|||||||
BillItem,
|
BillItem,
|
||||||
User,
|
User,
|
||||||
OperationLog,
|
OperationLog,
|
||||||
|
RoomInspection,
|
||||||
|
RoomInspectionDetail,
|
||||||
Deposit,
|
Deposit,
|
||||||
DepositInstallment,
|
DepositInstallment,
|
||||||
Classroom,
|
Classroom,
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
|||||||
expect(scheduleRepo.delete).not.toHaveBeenCalled();
|
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 = {
|
const rental = {
|
||||||
id: 1,
|
id: 1,
|
||||||
classroomId: 1,
|
classroomId: 1,
|
||||||
@@ -379,7 +379,10 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
|||||||
await service.cancel(1);
|
await service.cancel(1);
|
||||||
|
|
||||||
expect(rentalRepo.update).toHaveBeenCalledWith(1, { status: 'cancelled' });
|
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()', () => {
|
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 = {
|
const rental = {
|
||||||
id: 1,
|
id: 1,
|
||||||
classroomId: 1,
|
classroomId: 1,
|
||||||
lesseeOrganizationId: 2,
|
lesseeOrganizationId: 2,
|
||||||
startDate: '2026-03-01',
|
startDate: '2026-03-01',
|
||||||
endDate: '2026-03-31',
|
endDate: '2026-03-31',
|
||||||
status: 'cancelled',
|
status: 'active',
|
||||||
lesseeOrganization: { id: 2, name: 'Organization A' } as Organization,
|
lesseeOrganization: { id: 2, name: 'Organization A' } as Organization,
|
||||||
} as ClassroomRental;
|
} as ClassroomRental;
|
||||||
|
|
||||||
@@ -434,8 +437,12 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
|||||||
|
|
||||||
await service.remove(1);
|
await service.remove(1);
|
||||||
|
|
||||||
expect(scheduleRepo.delete).toHaveBeenCalledWith({ rentalId: 1, scheduleType: 'RENTAL' });
|
expect(rentalRepo.update).toHaveBeenCalledWith(1, { status: 'cancelled' });
|
||||||
expect(rentalRepo.delete).toHaveBeenCalledWith(1);
|
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' }] },
|
getTable: { name: 'attendance_records', columns: [{ name: 'id' }] },
|
||||||
});
|
});
|
||||||
await bootstrapCourseAttendance(runner);
|
const service = await bootstrapCourseAttendance(runner);
|
||||||
|
|
||||||
await service.ensureCourseAttendanceSchema();
|
await service.ensureCourseAttendanceSchema();
|
||||||
|
|
||||||
@@ -223,7 +223,7 @@ describe('DatabaseMigrationsService — course attendance schema', () => {
|
|||||||
? { name, columns: [{ name: 'id' }] }
|
? { name, columns: [{ name: 'id' }] }
|
||||||
: { name, columns: [{ name: 'id' }, { name: 'schedule_id' }, { name: 'attendance_session_id' }] },
|
: { name, columns: [{ name: 'id' }, { name: 'schedule_id' }, { name: 'attendance_session_id' }] },
|
||||||
);
|
);
|
||||||
await bootstrapCourseAttendance(runner);
|
const service = await bootstrapCourseAttendance(runner);
|
||||||
|
|
||||||
await service.ensureCourseAttendanceSchema();
|
await service.ensureCourseAttendanceSchema();
|
||||||
|
|
||||||
@@ -237,7 +237,7 @@ describe('DatabaseMigrationsService — course attendance schema', () => {
|
|||||||
getTables: [{ name: 'attendance_records', columns: [{ name: 'id' }] }],
|
getTables: [{ name: 'attendance_records', columns: [{ name: 'id' }] }],
|
||||||
getTable: { name: 'attendance_records', columns: [{ name: 'id' }] },
|
getTable: { name: 'attendance_records', columns: [{ name: 'id' }] },
|
||||||
});
|
});
|
||||||
await bootstrapCourseAttendance(runner);
|
const service = await bootstrapCourseAttendance(runner);
|
||||||
await service.ensureCourseAttendanceSchema();
|
await service.ensureCourseAttendanceSchema();
|
||||||
|
|
||||||
const createSql: string = (runner.query as jest.Mock).mock.calls
|
const createSql: string = (runner.query as jest.Mock).mock.calls
|
||||||
@@ -497,5 +497,5 @@ async function bootstrapCourseAttendance(runner: MockRunner) {
|
|||||||
{ provide: getDataSourceToken(), useValue: dataSource },
|
{ provide: getDataSourceToken(), useValue: dataSource },
|
||||||
],
|
],
|
||||||
}).compile();
|
}).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 { BillItem } from './bill-item.entity';
|
||||||
export { User } from './user.entity';
|
export { User } from './user.entity';
|
||||||
export { OperationLog } from './operation-log.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 { Deposit } from './deposit.entity';
|
||||||
export { DepositInstallment } from './deposit-installment.entity';
|
export { DepositInstallment } from './deposit-installment.entity';
|
||||||
export { Classroom, ClassroomStatus } from './classroom.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 { DataSource } from 'typeorm';
|
||||||
import { InitialSchema1784520727860 } from './migrations/1784520727860-InitialSchema';
|
import { InitialSchema1784520727860 } from './migrations/1784520727860-InitialSchema';
|
||||||
import { AddExamManagement1784600000000 } from './migrations/1784600000000-AddExamManagement';
|
import { AddExamManagement1784600000000 } from './migrations/1784600000000-AddExamManagement';
|
||||||
|
import { AddRoomInspections1784680000000 } from './migrations/1784680000000-AddRoomInspections';
|
||||||
import { config } from 'dotenv';
|
import { config } from 'dotenv';
|
||||||
|
|
||||||
config();
|
config();
|
||||||
@@ -19,7 +20,11 @@ export async function runMigrationsOnStartup(): Promise<void> {
|
|||||||
password: process.env.DB_PASSWORD || '',
|
password: process.env.DB_PASSWORD || '',
|
||||||
database: process.env.DB_DATABASE || 'dorm_billing',
|
database: process.env.DB_DATABASE || 'dorm_billing',
|
||||||
charset: 'utf8mb4',
|
charset: 'utf8mb4',
|
||||||
migrations: [InitialSchema1784520727860, AddExamManagement1784600000000],
|
migrations: [
|
||||||
|
InitialSchema1784520727860,
|
||||||
|
AddExamManagement1784600000000,
|
||||||
|
AddRoomInspections1784680000000,
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
await ds.initialize();
|
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).toContain('student:basic-view');
|
||||||
expect(accommodation.extras).not.toContain('organization:view');
|
expect(accommodation.extras).not.toContain('organization:view');
|
||||||
|
expect(accommodation.groups).toContain('room');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps classroom rental operations separate from accommodation operations', () => {
|
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: 'student:export', name: '导出学生', group: 'student' },
|
||||||
{ code: 'exam:view', name: '查看和录入考试成绩', group: 'exam' },
|
{ code: 'exam:view', name: '查看和录入考试成绩', group: 'exam' },
|
||||||
{ code: 'room:view', name: '查看宿舍', group: 'room' },
|
{ code: 'room:view', name: '查看宿舍', group: 'room' },
|
||||||
|
{ code: 'room:inspect', name: '宿舍查寝', group: 'room' },
|
||||||
{ code: 'room:create', name: '新增宿舍', group: 'room' },
|
{ code: 'room:create', name: '新增宿舍', group: 'room' },
|
||||||
{ code: 'room:edit', name: '编辑宿舍', group: 'room' },
|
{ code: 'room:edit', name: '编辑宿舍', group: 'room' },
|
||||||
{ code: 'room:delete', 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 { RoomsService } from './rooms.service';
|
||||||
import { RoomsController } from './rooms.controller';
|
import { RoomsController } from './rooms.controller';
|
||||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||||
|
import { PERMISSION_KEY } from '../auth/decorators/permission.decorator';
|
||||||
|
|
||||||
// ── Controller test mocks ──────────────────────────────────────────────
|
// ── 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 ───────────────────────────
|
// ── Service: batchImport boundary conditions ───────────────────────────
|
||||||
|
|
||||||
describe('RoomsService — batchImport boundary conditions', () => {
|
describe('RoomsService — batchImport boundary conditions', () => {
|
||||||
@@ -123,6 +132,7 @@ describe('RoomsService — batchImport boundary conditions', () => {
|
|||||||
bedRepo,
|
bedRepo,
|
||||||
lockerRepo,
|
lockerRepo,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
{ getByRoomsAndDate: jest.fn().mockResolvedValue(new Map()) } as never,
|
||||||
);
|
);
|
||||||
|
|
||||||
return { service, roomRepo, bedRepo };
|
return { service, roomRepo, bedRepo };
|
||||||
@@ -212,7 +222,11 @@ describe('RoomsController — boundary conditions', () => {
|
|||||||
cb(dataRow, 2);
|
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 file = { buffer: Buffer.from('fake') } as Express.Multer.File;
|
||||||
const req = { user: { id: 1, username: 'tester' } };
|
const req = { user: { id: 1, username: 'tester' } };
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import { RoomsService } from './rooms.service';
|
|||||||
import { CreateRoomDto, UpdateRoomDto } from './dto/room.dto';
|
import { CreateRoomDto, UpdateRoomDto } from './dto/room.dto';
|
||||||
import { CreateBedDto, UpdateBedDto, BatchCreateBedDto } from './dto/bed.dto';
|
import { CreateBedDto, UpdateBedDto, BatchCreateBedDto } from './dto/bed.dto';
|
||||||
import { CreateLockerDto, UpdateLockerDto, BatchCreateLockerDto } from './dto/locker.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 { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||||
import { extractRequestInfo } from '../common/request-utils';
|
import { extractRequestInfo } from '../common/request-utils';
|
||||||
@@ -31,6 +33,7 @@ export class RoomsController {
|
|||||||
constructor(
|
constructor(
|
||||||
private service: RoomsService,
|
private service: RoomsService,
|
||||||
private logService: OperationLogsService,
|
private logService: OperationLogsService,
|
||||||
|
private inspectionsService: RoomInspectionsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@@ -54,6 +57,35 @@ export class RoomsController {
|
|||||||
return this.service.getRoomVisual(asOf);
|
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')
|
@Get('template')
|
||||||
@RequirePermission('room:view')
|
@RequirePermission('room:view')
|
||||||
async downloadTemplate(@Res() res: Response) {
|
async downloadTemplate(@Res() res: Response) {
|
||||||
|
|||||||
@@ -5,14 +5,28 @@ import { Occupancy } from '../entities/occupancy.entity';
|
|||||||
import { RoomExpense } from '../entities/room-expense.entity';
|
import { RoomExpense } from '../entities/room-expense.entity';
|
||||||
import { Bed } from '../entities/bed.entity';
|
import { Bed } from '../entities/bed.entity';
|
||||||
import { Locker } from '../entities/locker.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 { RoomsService } from './rooms.service';
|
||||||
import { RoomsController } from './rooms.controller';
|
import { RoomsController } from './rooms.controller';
|
||||||
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
||||||
|
import { RoomInspectionsService } from './room-inspections.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Room, Occupancy, RoomExpense, Bed, Locker]), OperationLogsModule],
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([
|
||||||
|
Room,
|
||||||
|
Occupancy,
|
||||||
|
RoomExpense,
|
||||||
|
Bed,
|
||||||
|
Locker,
|
||||||
|
RoomInspection,
|
||||||
|
RoomInspectionDetail,
|
||||||
|
]),
|
||||||
|
OperationLogsModule,
|
||||||
|
],
|
||||||
controllers: [RoomsController],
|
controllers: [RoomsController],
|
||||||
providers: [RoomsService],
|
providers: [RoomsService, RoomInspectionsService],
|
||||||
exports: [RoomsService],
|
exports: [RoomsService, RoomInspectionsService],
|
||||||
})
|
})
|
||||||
export class RoomsModule {}
|
export class RoomsModule {}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ describe('RoomsService — capacity consistency', () => {
|
|||||||
bedRepo,
|
bedRepo,
|
||||||
{} as Repository<Locker>,
|
{} as Repository<Locker>,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
{ getByRoomsAndDate: jest.fn().mockResolvedValue(new Map()) } as never,
|
||||||
);
|
);
|
||||||
|
|
||||||
return { service, roomRepo, bedRepo, occupancyRepo };
|
return { service, roomRepo, bedRepo, occupancyRepo };
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { Locker } from '../entities/locker.entity';
|
|||||||
import { CreateRoomDto, UpdateRoomDto } from './dto/room.dto';
|
import { CreateRoomDto, UpdateRoomDto } from './dto/room.dto';
|
||||||
import { CreateBedDto, UpdateBedDto, BatchCreateBedDto } from './dto/bed.dto';
|
import { CreateBedDto, UpdateBedDto, BatchCreateBedDto } from './dto/bed.dto';
|
||||||
import { CreateLockerDto, UpdateLockerDto, BatchCreateLockerDto } from './dto/locker.dto';
|
import { CreateLockerDto, UpdateLockerDto, BatchCreateLockerDto } from './dto/locker.dto';
|
||||||
|
import { RoomInspectionsService } from './room-inspections.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RoomsService {
|
export class RoomsService {
|
||||||
@@ -29,6 +30,7 @@ export class RoomsService {
|
|||||||
@InjectRepository(Bed) private bedRepo: Repository<Bed>,
|
@InjectRepository(Bed) private bedRepo: Repository<Bed>,
|
||||||
@InjectRepository(Locker) private lockerRepo: Repository<Locker>,
|
@InjectRepository(Locker) private lockerRepo: Repository<Locker>,
|
||||||
private dataSource: DataSource,
|
private dataSource: DataSource,
|
||||||
|
private readonly inspectionsService: RoomInspectionsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -235,7 +237,7 @@ export class RoomsService {
|
|||||||
async getRoomVisual(asOf?: string) {
|
async getRoomVisual(asOf?: string) {
|
||||||
// asOf 为空 = 实时(今天)。带 asOf = 还原该日期结束时的历史入住快照。
|
// asOf 为空 = 实时(今天)。带 asOf = 还原该日期结束时的历史入住快照。
|
||||||
const isHistorical = !!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({
|
const rooms = await this.repo.find({
|
||||||
@@ -244,13 +246,11 @@ export class RoomsService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const occupancies = await this.occRepo.find({
|
const occupancies = await this.occRepo.find({
|
||||||
where: isHistorical
|
where: [
|
||||||
? [
|
|
||||||
{ checkInDate: LessThanOrEqual(targetDate), checkOutDate: IsNull() },
|
{ checkInDate: LessThanOrEqual(targetDate), checkOutDate: IsNull() },
|
||||||
{ checkInDate: LessThanOrEqual(targetDate), checkOutDate: MoreThanOrEqual(targetDate) },
|
{ checkInDate: LessThanOrEqual(targetDate), checkOutDate: MoreThanOrEqual(targetDate) },
|
||||||
]
|
],
|
||||||
: { checkOutDate: IsNull() },
|
relations: ['student', 'student.organization', 'responsibleOrganization', 'bed'],
|
||||||
relations: ['student', 'student.organization', 'responsibleOrganization'],
|
|
||||||
order: { checkInDate: 'ASC' },
|
order: { checkInDate: 'ASC' },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -264,7 +264,10 @@ export class RoomsService {
|
|||||||
const days = Math.max(1, Math.ceil((refTime - checkIn.getTime()) / (1000 * 60 * 60 * 24)));
|
const days = Math.max(1, Math.ceil((refTime - checkIn.getTime()) / (1000 * 60 * 60 * 24)));
|
||||||
occMap.get(occ.roomId)!.push({
|
occMap.get(occ.roomId)!.push({
|
||||||
studentId: occ.studentId,
|
studentId: occ.studentId,
|
||||||
|
occupancyId: occ.id,
|
||||||
studentName: occ.student?.name || '未知',
|
studentName: occ.student?.name || '未知',
|
||||||
|
bedId: occ.bedId ?? null,
|
||||||
|
bedNumber: occ.bed?.bedNumber || null,
|
||||||
checkInDate: occ.checkInDate,
|
checkInDate: occ.checkInDate,
|
||||||
billingStartDate: occ.billingStartDate,
|
billingStartDate: occ.billingStartDate,
|
||||||
days,
|
days,
|
||||||
@@ -296,10 +299,19 @@ export class RoomsService {
|
|||||||
if (bed.status === 'occupied') entry.occupied++;
|
if (bed.status === 'occupied') entry.occupied++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inspectionMap = await this.inspectionsService.getByRoomsAndDate(
|
||||||
|
visibleRooms.map((room) => room.id),
|
||||||
|
targetDate,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
buildings,
|
buildings,
|
||||||
rooms: visibleRooms.map((room) => {
|
rooms: visibleRooms.map((room) => {
|
||||||
const occ = occMap.get(room.id) || [];
|
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))];
|
const orgs = [...new Set(occ.map((o: any) => o.organization).filter(Boolean))];
|
||||||
let orgLabel: string | null = null;
|
let orgLabel: string | null = null;
|
||||||
if (orgs.length > 0 && occ.length > 0) {
|
if (orgs.length > 0 && occ.length > 0) {
|
||||||
@@ -322,7 +334,19 @@ export class RoomsService {
|
|||||||
currentCount: occ.length,
|
currentCount: occ.length,
|
||||||
totalBeds: bedMap.get(room.id)?.total ?? 0,
|
totalBeds: bedMap.get(room.id)?.total ?? 0,
|
||||||
occupiedBeds: bedMap.get(room.id)?.occupied ?? 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,
|
orgLabel,
|
||||||
organizationColor,
|
organizationColor,
|
||||||
organizationIds,
|
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(
|
async batchImport(
|
||||||
rows: {
|
rows: {
|
||||||
roomNumber: string;
|
roomNumber: string;
|
||||||
|
|||||||
@@ -371,14 +371,9 @@ describe('ScheduleSyncService — dedup', () => {
|
|||||||
seen.add(k);
|
seen.add(k);
|
||||||
}
|
}
|
||||||
|
|
||||||
// At least one item exists for 07-08 (proving overlap was handled)
|
// Compare the exact Beijing-midnight epoch so this assertion is independent of host timezone.
|
||||||
// work_date is epoch ms at 00:00:00+08:00; convert back to date string
|
const july8Epoch = new Date('2026-07-08T00:00:00+08:00').getTime();
|
||||||
const fmtDate = (epochMs: number) => {
|
const july8Items = batchItems.filter((item) => item.work_date === july8Epoch);
|
||||||
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');
|
|
||||||
expect(july8Items.length).toBe(1);
|
expect(july8Items.length).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user