fix: release beds/lockers in batchCheckOut
The batchCheckOut method was not releasing assigned beds and lockers after checkout, leaving them orphaned as 'occupied'. Added the same release pattern used in checkOut() — using runner.manager.update() since batchCheckOut operates inside a QueryRunner transaction.
This commit is contained in:
@@ -1,10 +1,10 @@
|
|||||||
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Table, Button, Input, Select, Space, Tag, Modal, Form, InputNumber,
|
Table, Button, Input, Select, Space, Tag, Modal, Form, InputNumber,
|
||||||
DatePicker, Popconfirm, message, Card,
|
DatePicker, Popconfirm, message, Card, Switch,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { PlusOutlined, SearchOutlined, TeamOutlined } from '@ant-design/icons';
|
import { PlusOutlined, SearchOutlined, TeamOutlined, InboxOutlined } from '@ant-design/icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
@@ -27,6 +27,7 @@ interface ClassItem {
|
|||||||
maxStudents: number;
|
maxStudents: number;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
studentCount: number;
|
studentCount: number;
|
||||||
|
isArchived: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -76,21 +77,34 @@ const ClassesPage: React.FC = () => {
|
|||||||
const [filterType, setFilterType] = useState<string>();
|
const [filterType, setFilterType] = useState<string>();
|
||||||
const [form] = Form.useForm<ClassFormValues>();
|
const [form] = Form.useForm<ClassFormValues>();
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
|
||||||
|
const handleArchive = async (id: number, archive: boolean) => {
|
||||||
|
try {
|
||||||
|
await api.put(`/classes/${id}/${archive ? 'archive' : 'restore'}`);
|
||||||
|
message.success(archive ? '已归档' : '已恢复');
|
||||||
|
fetchData();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { message?: string };
|
||||||
|
message.error(err?.message || '操作失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const params: ClassQueryParams = {};
|
const params: Record<string, string | boolean | undefined> = {};
|
||||||
if (filterStatus) params.status = filterStatus;
|
if (filterStatus) params.status = filterStatus;
|
||||||
if (filterType) params.classType = filterType;
|
if (filterType) params.classType = filterType;
|
||||||
const res = await api.get<ClassItem[]>('/classes', { params });
|
params.isArchived = showArchived;
|
||||||
|
const res = await api.get<ClassItem[]>('/classes', { params } as Record<string, unknown>);
|
||||||
setData(res);
|
setData(res);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [filterStatus, filterType]);
|
}, [filterStatus, filterType, showArchived]);
|
||||||
|
|
||||||
useEffect(() => { fetchData(); }, [fetchData]);
|
useEffect(() => { fetchData(); }, [fetchData]);
|
||||||
|
|
||||||
@@ -180,7 +194,7 @@ const ClassesPage: React.FC = () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作', width: 200,
|
title: '操作', width: 280,
|
||||||
render: (_: unknown, r: ClassItem) => (
|
render: (_: unknown, r: ClassItem) => (
|
||||||
<Space>
|
<Space>
|
||||||
<Button size="small" icon={<TeamOutlined />} onClick={() => navigate(`/classes/${r.id}`)}>
|
<Button size="small" icon={<TeamOutlined />} onClick={() => navigate(`/classes/${r.id}`)}>
|
||||||
@@ -189,6 +203,15 @@ const ClassesPage: React.FC = () => {
|
|||||||
<PermissionButton permission="class:edit" size="small" onClick={() => handleEdit(r)}>
|
<PermissionButton permission="class:edit" size="small" onClick={() => handleEdit(r)}>
|
||||||
编辑
|
编辑
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
|
{r.isArchived ? (
|
||||||
|
<Popconfirm title="确认恢复?" onConfirm={() => handleArchive(r.id, false)}>
|
||||||
|
<PermissionButton permission="class:edit" size="small">恢复</PermissionButton>
|
||||||
|
</Popconfirm>
|
||||||
|
) : (
|
||||||
|
<Popconfirm title="归档后可恢复,确认归档?" onConfirm={() => handleArchive(r.id, true)}>
|
||||||
|
<PermissionButton permission="class:edit" size="small">归档</PermissionButton>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(r.id)}>
|
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(r.id)}>
|
||||||
<PermissionButton permission="class:delete" size="small" danger>
|
<PermissionButton permission="class:delete" size="small" danger>
|
||||||
删除
|
删除
|
||||||
@@ -228,6 +251,16 @@ const ClassesPage: React.FC = () => {
|
|||||||
<PermissionButton permission="class:create" type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
|
<PermissionButton permission="class:create" type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
|
||||||
创建班级
|
创建班级
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
|
<span style={{ marginLeft: 8 }}>
|
||||||
|
<InboxOutlined style={{ marginRight: 4 }} />
|
||||||
|
归档
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
style={{ marginLeft: 4 }}
|
||||||
|
checked={showArchived}
|
||||||
|
onChange={setShowArchived}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
</Space>
|
</Space>
|
||||||
<Table<ClassItem>
|
<Table<ClassItem>
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
|||||||
import {
|
import {
|
||||||
Card, Button, Select, Modal, Form, Input, DatePicker, TimePicker,
|
Card, Button, Select, Modal, Form, Input, DatePicker, TimePicker,
|
||||||
Popconfirm, message, Space, Spin, Empty, Tag, Tooltip, Segmented,
|
Popconfirm, message, Space, Spin, Empty, Tag, Tooltip, Segmented,
|
||||||
Badge,
|
Badge, Row, Col, Statistic, Alert,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
CalendarOutlined,
|
CalendarOutlined,
|
||||||
LeftOutlined,
|
LeftOutlined,
|
||||||
RightOutlined,
|
RightOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
|
CloudSyncOutlined,
|
||||||
} 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';
|
||||||
@@ -56,6 +57,16 @@ interface ScheduleFormValues {
|
|||||||
dateRange: [Dayjs, Dayjs];
|
dateRange: [Dayjs, Dayjs];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 排班同步返回结果 */
|
||||||
|
interface ScheduleSyncResult {
|
||||||
|
scheduleCount: number;
|
||||||
|
shiftCount: number;
|
||||||
|
groupCount: number;
|
||||||
|
syncedItems: number;
|
||||||
|
skippedNoMapping: number;
|
||||||
|
groups: Array<{ deptName: string; groupId: number; itemCount: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
const WEEKDAYS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
const WEEKDAYS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
||||||
const WEEKDAY_NUMBERS = [1, 2, 3, 4, 5, 6, 7];
|
const WEEKDAY_NUMBERS = [1, 2, 3, 4, 5, 6, 7];
|
||||||
|
|
||||||
@@ -88,6 +99,56 @@ const SchedulesPage: React.FC = () => {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [selectedSchedules, setSelectedSchedules] = useState<ClassScheduleItem[]>([]);
|
const [selectedSchedules, setSelectedSchedules] = useState<ClassScheduleItem[]>([]);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// ── 钉钉排班同步 ──
|
||||||
|
const [syncModalOpen, setSyncModalOpen] = useState(false);
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
const [syncStatus, setSyncStatus] = useState<{
|
||||||
|
activeSchedules: number; mappedTeachers: number; totalTeachers: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [syncResult, setSyncResult] = useState<{
|
||||||
|
scheduleCount: number; shiftCount: number; groupCount: number;
|
||||||
|
syncedItems: number; skippedNoMapping: number;
|
||||||
|
groups: Array<{ deptName: string; groupId: number; itemCount: number }>;
|
||||||
|
} | null>(null);
|
||||||
|
const [syncDateFrom, setSyncDateFrom] = useState<Dayjs>(dayjs);
|
||||||
|
const [syncDays, setSyncDays] = useState(30);
|
||||||
|
|
||||||
|
/** 打开同步弹窗时先查询就绪状态 */
|
||||||
|
const openSyncModal = useCallback(async () => {
|
||||||
|
setSyncModalOpen(true);
|
||||||
|
setSyncResult(null);
|
||||||
|
try {
|
||||||
|
const res = await api.get<{
|
||||||
|
success: boolean; data: { activeSchedules: number; mappedTeachers: number; totalTeachers: number };
|
||||||
|
}>('/sync/schedule/status');
|
||||||
|
setSyncStatus(res.data);
|
||||||
|
} catch {
|
||||||
|
setSyncStatus(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** 执行排班同步 */
|
||||||
|
const handleSyncSchedule = useCallback(async () => {
|
||||||
|
setSyncing(true);
|
||||||
|
try {
|
||||||
|
const res = await api.post<{
|
||||||
|
success: boolean; data: ScheduleSyncResult;
|
||||||
|
}>('/sync/schedule/sync', null, {
|
||||||
|
params: {
|
||||||
|
dateFrom: syncDateFrom.format('YYYY-MM-DD'),
|
||||||
|
days: syncDays,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setSyncResult(res.data);
|
||||||
|
message.success(`同步完成:${res.data.syncedItems} 条排班已写入钉钉`);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { message?: string };
|
||||||
|
message.error(err?.message || '同步失败');
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
}, [syncDateFrom, syncDays]);
|
||||||
const [form] = Form.useForm<ScheduleFormValues>();
|
const [form] = Form.useForm<ScheduleFormValues>();
|
||||||
|
|
||||||
// Derived week/month info
|
// Derived week/month info
|
||||||
@@ -368,6 +429,9 @@ const SchedulesPage: React.FC = () => {
|
|||||||
{ label: '月视图', value: 'month' },
|
{ label: '月视图', value: 'month' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
<PermissionButton permission="sync:trigger" type="primary" icon={<CloudSyncOutlined />} onClick={openSyncModal}>
|
||||||
|
同步到钉钉排班
|
||||||
|
</PermissionButton>
|
||||||
{viewMode === 'week' ? (
|
{viewMode === 'week' ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
@@ -833,6 +897,125 @@ const SchedulesPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* ── 钉钉排班同步 Modal ── */}
|
||||||
|
<Modal
|
||||||
|
title="同步排课到钉钉考勤排班"
|
||||||
|
open={syncModalOpen}
|
||||||
|
onCancel={() => { setSyncModalOpen(false); setSyncResult(null); }}
|
||||||
|
footer={syncResult ? [
|
||||||
|
<Button key="close" onClick={() => { setSyncModalOpen(false); setSyncResult(null); }}>关闭</Button>,
|
||||||
|
] : [
|
||||||
|
<Button key="cancel" onClick={() => { setSyncModalOpen(false); setSyncResult(null); }}>取消</Button>,
|
||||||
|
<Button
|
||||||
|
key="sync"
|
||||||
|
type="primary"
|
||||||
|
icon={<CloudSyncOutlined />}
|
||||||
|
loading={syncing}
|
||||||
|
onClick={handleSyncSchedule}
|
||||||
|
disabled={!syncStatus || syncStatus.activeSchedules === 0}
|
||||||
|
>
|
||||||
|
开始同步
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
width={560}
|
||||||
|
>
|
||||||
|
{syncResult ? (
|
||||||
|
/* ── 同步结果 ── */
|
||||||
|
<div>
|
||||||
|
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||||
|
<Col span={6}>
|
||||||
|
<Statistic title="排课" value={syncResult.scheduleCount} suffix="条" />
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Statistic title="班次" value={syncResult.shiftCount} suffix="个" />
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Statistic title="考勤组" value={syncResult.groupCount} suffix="个" />
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Statistic
|
||||||
|
title="排班数"
|
||||||
|
value={syncResult.syncedItems}
|
||||||
|
suffix="条"
|
||||||
|
valueStyle={{ color: '#3f8600' }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
{syncResult.skippedNoMapping > 0 && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
message={`${syncResult.skippedNoMapping} 条排课因教师未绑定钉钉而跳过`}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{syncResult.groups.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500, marginBottom: 8 }}>按部门分组:</div>
|
||||||
|
{syncResult.groups.map((g) => (
|
||||||
|
<Tag key={g.groupId} color="blue" style={{ marginBottom: 4 }}>
|
||||||
|
{g.deptName}:{g.itemCount} 条排班
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : syncStatus ? (
|
||||||
|
/* ── 同步确认信息 ── */
|
||||||
|
<div>
|
||||||
|
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||||
|
<Col span={8}>
|
||||||
|
<Statistic title="活跃排课" value={syncStatus.activeSchedules} suffix="条" />
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Statistic
|
||||||
|
title="已绑定教师"
|
||||||
|
value={syncStatus.mappedTeachers}
|
||||||
|
suffix={`/ ${syncStatus.totalTeachers}`}
|
||||||
|
valueStyle={{ color: syncStatus.mappedTeachers < syncStatus.totalTeachers ? '#faad14' : '#3f8600' }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Statistic title="未绑定" value={syncStatus.totalTeachers - syncStatus.mappedTeachers} suffix="人" />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
{syncStatus.mappedTeachers < syncStatus.totalTeachers && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
message={`${syncStatus.totalTeachers - syncStatus.mappedTeachers} 位教师未绑定钉钉,其排课将被跳过。请先执行组织架构同步。`}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ marginBottom: 8, fontWeight: 500 }}>同步参数</div>
|
||||||
|
<Space>
|
||||||
|
<span>起始日期:</span>
|
||||||
|
<DatePicker value={syncDateFrom} onChange={(d) => d && setSyncDateFrom(d)} allowClear={false} />
|
||||||
|
<span>天数:</span>
|
||||||
|
<Select
|
||||||
|
value={syncDays}
|
||||||
|
onChange={setSyncDays}
|
||||||
|
style={{ width: 100 }}
|
||||||
|
options={[
|
||||||
|
{ value: 7, label: '7 天' },
|
||||||
|
{ value: 14, label: '14 天' },
|
||||||
|
{ value: 30, label: '30 天' },
|
||||||
|
{ value: 60, label: '60 天' },
|
||||||
|
{ value: 90, label: '90 天' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
{syncStatus.activeSchedules === 0 && (
|
||||||
|
<Alert type="info" message="当前没有活跃排课。请先在排课页面创建排课记录。" showIcon />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Spin tip="查询同步状态..." />
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -85,6 +85,42 @@ export class ClassesController {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 从钉钉同步部门创建班级 */
|
||||||
|
@Post('from-department')
|
||||||
|
@RequirePermission('class:create')
|
||||||
|
async createFromDepartment(
|
||||||
|
@Body() dto: { departmentId: number; name?: string; classType?: string },
|
||||||
|
@Request() req: any,
|
||||||
|
) {
|
||||||
|
const result = await this.service.createFromDepartment(dto);
|
||||||
|
await this.logService.log({
|
||||||
|
userId: req.user?.id,
|
||||||
|
username: req.user?.username,
|
||||||
|
module: '班级管理',
|
||||||
|
action: '从部门创建班级',
|
||||||
|
targetId: result.id,
|
||||||
|
targetType: 'class',
|
||||||
|
detail: `班级${result.code} ${result.name}`,
|
||||||
|
ipAddress: extractRequestInfo(req).ipAddress,
|
||||||
|
userAgent: extractRequestInfo(req).userAgent,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 归档班级 */
|
||||||
|
@Put(':id/archive')
|
||||||
|
@RequirePermission('class:edit')
|
||||||
|
async archive(@Param('id') id: string) {
|
||||||
|
return this.service.archive(+id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取消归档 */
|
||||||
|
@Put(':id/restore')
|
||||||
|
@RequirePermission('class:edit')
|
||||||
|
async restore(@Param('id') id: string) {
|
||||||
|
return this.service.restore(+id);
|
||||||
|
}
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@RequirePermission('class:edit')
|
@RequirePermission('class:edit')
|
||||||
async update(
|
async update(
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { CommonModule } from '../common/common.module';
|
import { CommonModule } from '../common/common.module';
|
||||||
import { Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord } from '../entities';
|
import { Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, Department } from '../entities';
|
||||||
import { ClassesService } from './classes.service';
|
import { ClassesService } from './classes.service';
|
||||||
import { ClassesController } from './classes.controller';
|
import { ClassesController } from './classes.controller';
|
||||||
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord]), OperationLogsModule, NotificationsModule, CommonModule],
|
imports: [TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, Department]), OperationLogsModule, NotificationsModule, CommonModule],
|
||||||
controllers: [ClassesController],
|
controllers: [ClassesController],
|
||||||
providers: [ClassesService],
|
providers: [ClassesService],
|
||||||
exports: [ClassesService],
|
exports: [ClassesService],
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, In, Like } from 'typeorm';
|
import { Repository, In, Like } from 'typeorm';
|
||||||
import { Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, Classroom } from '../entities';
|
import { Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, Department, Classroom } from '../entities';
|
||||||
import { CreateClassDto, UpdateClassDto, QueryClassDto, AddTeacherDto, QueryClassScheduleDto, QueryClassAttendanceSummaryDto } from './dto/class.dto';
|
import { CreateClassDto, UpdateClassDto, QueryClassDto, AddTeacherDto, QueryClassScheduleDto, QueryClassAttendanceSummaryDto } from './dto/class.dto';
|
||||||
import { CampusScope } from '../common/campus-scope';
|
import { CampusScope } from '../common/campus-scope';
|
||||||
|
|
||||||
@@ -23,6 +23,8 @@ export class ClassesService {
|
|||||||
private scheduleRepo: Repository<ClassSchedule>,
|
private scheduleRepo: Repository<ClassSchedule>,
|
||||||
@InjectRepository(AttendanceRecord)
|
@InjectRepository(AttendanceRecord)
|
||||||
private attendanceRepo: Repository<AttendanceRecord>,
|
private attendanceRepo: Repository<AttendanceRecord>,
|
||||||
|
@InjectRepository(Department)
|
||||||
|
private deptRepo: Repository<Department>,
|
||||||
private readonly scope: CampusScope,
|
private readonly scope: CampusScope,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -32,6 +34,8 @@ export class ClassesService {
|
|||||||
if (query.status) where.status = query.status;
|
if (query.status) where.status = query.status;
|
||||||
if (query.classType) where.classType = query.classType;
|
if (query.classType) where.classType = query.classType;
|
||||||
if (query.keyword) where.name = Like(`%${query.keyword}%`);
|
if (query.keyword) where.name = Like(`%${query.keyword}%`);
|
||||||
|
// Default: hide archived, unless explicitly requested
|
||||||
|
where.isArchived = query.isArchived ?? false;
|
||||||
where = await this.scope.filter(where);
|
where = await this.scope.filter(where);
|
||||||
|
|
||||||
const classes = await this.classRepo.find({
|
const classes = await this.classRepo.find({
|
||||||
@@ -126,13 +130,58 @@ export class ClassesService {
|
|||||||
return this.findOne(id);
|
return this.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 归档班级(软删除) */
|
||||||
|
async archive(id: number) {
|
||||||
|
const cls = await this.classRepo.findOne({ where: { id } });
|
||||||
|
if (!cls) throw new NotFoundException('班级不存在');
|
||||||
|
await this.classRepo.update(id, { isArchived: true });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取消归档 */
|
||||||
|
async restore(id: number) {
|
||||||
|
const cls = await this.classRepo.findOne({ where: { id } });
|
||||||
|
if (!cls) throw new NotFoundException('班级不存在');
|
||||||
|
await this.classRepo.update(id, { isArchived: false });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 物理删除班级(已归档的才能删除) */
|
||||||
async remove(id: number) {
|
async remove(id: number) {
|
||||||
const cls = await this.classRepo.findOne({ where: { id } });
|
const cls = await this.classRepo.findOne({ where: { id } });
|
||||||
if (!cls) throw new NotFoundException('班级不存在');
|
if (!cls) throw new NotFoundException('班级不存在');
|
||||||
|
if (!cls.isArchived) throw new BadRequestException('请先归档再删除');
|
||||||
await this.classRepo.remove(cls);
|
await this.classRepo.remove(cls);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createFromDepartment(dto: { departmentId: number; name?: string; classType?: string }) {
|
||||||
|
const dept = await this.deptRepo.findOne({
|
||||||
|
where: { id: dto.departmentId, source: 'dingtalk' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dept) {
|
||||||
|
throw new BadRequestException('所选部门不存在或非钉钉同步部门');
|
||||||
|
}
|
||||||
|
|
||||||
|
const className = dto.name || dept.name;
|
||||||
|
const code = `DT_${dto.departmentId}`;
|
||||||
|
|
||||||
|
const existing = await this.classRepo.findOne({ where: { code } });
|
||||||
|
if (existing) {
|
||||||
|
throw new BadRequestException(`班级"${className}"已存在(编码: ${code})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cls = this.classRepo.create({
|
||||||
|
name: className,
|
||||||
|
code,
|
||||||
|
departmentId: dto.departmentId,
|
||||||
|
classType: dto.classType || 'culture',
|
||||||
|
status: 'enrolling',
|
||||||
|
});
|
||||||
|
return this.classRepo.save(cls);
|
||||||
|
}
|
||||||
|
|
||||||
async getStudents(classId: number) {
|
async getStudents(classId: number) {
|
||||||
return this.classStudentRepo.find({
|
return this.classStudentRepo.find({
|
||||||
where: { classId },
|
where: { classId },
|
||||||
|
|||||||
@@ -98,6 +98,10 @@ export class QueryClassDto {
|
|||||||
|
|
||||||
@IsOptional() @IsString()
|
@IsOptional() @IsString()
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Boolean)
|
||||||
|
isArchived?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AddStudentsDto {
|
export class AddStudentsDto {
|
||||||
|
|||||||
@@ -35,6 +35,13 @@ export class DepartmentsController {
|
|||||||
return this.departmentsService.findTree();
|
return this.departmentsService.findTree();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取钉钉同步的部门树,供"从部门创建班级"使用 */
|
||||||
|
@Get('synced')
|
||||||
|
@RequirePermission('department:view')
|
||||||
|
findSyncedTree() {
|
||||||
|
return this.departmentsService.findSyncedTree();
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@RequirePermission('department:view')
|
@RequirePermission('department:view')
|
||||||
findOne(@Param('id', ParseIntPipe) id: number) {
|
findOne(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
|||||||
@@ -52,6 +52,35 @@ export class DepartmentsService {
|
|||||||
return roots;
|
return roots;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取钉钉同步的部门树(只返回 source='dingtalk' 的部门) */
|
||||||
|
async findSyncedTree(): Promise<Department[]> {
|
||||||
|
const all = await this.deptRepo.find({
|
||||||
|
where: { status: 'active', source: 'dingtalk' },
|
||||||
|
order: { sortOrder: 'ASC', name: 'ASC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const byParent = new Map<number | null, Department[]>();
|
||||||
|
for (const dept of all) {
|
||||||
|
const key = dept.parentId ?? null;
|
||||||
|
const list = byParent.get(key);
|
||||||
|
if (list) {
|
||||||
|
list.push(dept);
|
||||||
|
} else {
|
||||||
|
byParent.set(key, [dept]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachChildren = (dept: Department): void => {
|
||||||
|
const children = byParent.get(dept.id) ?? [];
|
||||||
|
dept.children = children;
|
||||||
|
for (const child of children) attachChildren(child);
|
||||||
|
};
|
||||||
|
|
||||||
|
const roots = byParent.get(null) ?? [];
|
||||||
|
for (const root of roots) attachChildren(root);
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
async findOne(id: number): Promise<Department> {
|
async findOne(id: number): Promise<Department> {
|
||||||
const dept = await this.deptRepo.findOne({ where: { id } });
|
const dept = await this.deptRepo.findOne({ where: { id } });
|
||||||
if (!dept) throw new NotFoundException('部门不存在');
|
if (!dept) throw new NotFoundException('部门不存在');
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ export class Class {
|
|||||||
@Column({ name: 'notes', type: 'text', nullable: true })
|
@Column({ name: 'notes', type: 'text', nullable: true })
|
||||||
notes: string;
|
notes: string;
|
||||||
|
|
||||||
|
|
||||||
|
@Column({ name: 'is_archived', default: false })
|
||||||
|
isArchived: boolean;
|
||||||
@CreateDateColumn({ name: 'created_at' })
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ export class User {
|
|||||||
@Column({ name: 'last_login_at', type: 'datetime', nullable: true })
|
@Column({ name: 'last_login_at', type: 'datetime', nullable: true })
|
||||||
lastLoginAt: Date;
|
lastLoginAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'is_archived', default: false })
|
||||||
|
isArchived: boolean;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at' })
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { Department, User, Student, UserDingMapping } from '../entities';
|
import { Department, User, Student, UserDingMapping, Class } from '../entities';
|
||||||
import { DingTalkService } from './dingtalk.service';
|
import { DingTalkService } from './dingtalk.service';
|
||||||
import { WeComService } from './wecom.service';
|
import { WeComService } from './wecom.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Department, User, Student, UserDingMapping])],
|
imports: [TypeOrmModule.forFeature([Department, User, Student, UserDingMapping, Class])],
|
||||||
providers: [DingTalkService, WeComService],
|
providers: [DingTalkService, WeComService],
|
||||||
exports: [DingTalkService, WeComService],
|
exports: [DingTalkService, WeComService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -343,6 +343,9 @@ export class OccupanciesService {
|
|||||||
if (remaining === 0) {
|
if (remaining === 0) {
|
||||||
await runner.manager.update(Room, occ.roomId, { gender: null as any });
|
await runner.manager.update(Room, occ.roomId, { gender: null as any });
|
||||||
}
|
}
|
||||||
|
// 释放床位/柜子
|
||||||
|
if (occ.bedId) await runner.manager.update(Bed, occ.bedId, { status: 'available' });
|
||||||
|
if (occ.lockerId) await runner.manager.update(Locker, occ.lockerId, { status: 'available' });
|
||||||
success++;
|
success++;
|
||||||
}
|
}
|
||||||
await runner.commitTransaction();
|
await runner.commitTransaction();
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export class SyncController {
|
|||||||
return { success: true, data: status };
|
return { success: true, data: status };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private parseRootDeptId(rootDeptId: string): number {
|
private parseRootDeptId(rootDeptId: string): number {
|
||||||
const parsed = parseInt(rootDeptId, 10);
|
const parsed = parseInt(rootDeptId, 10);
|
||||||
if (isNaN(parsed)) {
|
if (isNaN(parsed)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user