feat: add attendance device SN classroom bindings
This commit is contained in:
@@ -31,6 +31,7 @@ const SchedulesPage = lazy(() => import('./pages/Schedules'));
|
||||
const RolesPage = lazy(() => import('./pages/Roles'));
|
||||
const PermissionsPage = lazy(() => import('./pages/Permissions'));
|
||||
const AttendancePage = lazy(() => import('./pages/Attendance'));
|
||||
const AttendanceDevicesPage = lazy(() => import('./pages/AttendanceDevices'));
|
||||
const TeacherWorkspacePage = lazy(() => import('./pages/TeacherWorkspace'));
|
||||
const NotificationsPage = lazy(() => import('./pages/Notifications'));
|
||||
const IntegrationConfigPage = lazy(() => import('./pages/IntegrationConfig'));
|
||||
@@ -240,6 +241,16 @@ const App: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
<Route
|
||||
path="attendance-devices"
|
||||
element={
|
||||
<PermissionRoute permission="classroom:view">
|
||||
<AttendanceDevicesPage />
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="attendance"
|
||||
element={
|
||||
|
||||
@@ -85,6 +85,7 @@ const SECTIONS: MenuSection[] = [
|
||||
children: [
|
||||
{ key: '/classroom-schedule', label: '教室排期', icon: 'calendar', permission: 'rental:view' },
|
||||
{ key: '/classrooms', label: '教室管理', icon: 'classroom', permission: 'classroom:view' },
|
||||
{ key: '/attendance-devices', label: '考勤机绑定', icon: 'attendance', permission: 'classroom:view' },
|
||||
{ key: '/classroom-rentals', label: '租赁订单', icon: 'rental', permission: 'rental:view' },
|
||||
{ key: '/organizations', label: '机构管理', icon: 'organization', permission: 'organization:view' },
|
||||
],
|
||||
|
||||
@@ -20,6 +20,7 @@ export const PERMISSION_PAGES: readonly PermissionPage[] = [
|
||||
{ path: '/schedules', permission: 'schedule:view' },
|
||||
{ path: '/classroom-schedule', permission: 'rental:view' },
|
||||
{ path: '/classrooms', permission: 'classroom:view' },
|
||||
{ path: '/attendance-devices', permission: 'classroom:view' },
|
||||
{ path: '/classroom-rentals', permission: 'rental:view' },
|
||||
{ path: '/organizations', permission: 'organization:view' },
|
||||
{ path: '/expenses', permission: 'expense:view' },
|
||||
|
||||
215
apps/admin/src/pages/AttendanceDevices.tsx
Normal file
215
apps/admin/src/pages/AttendanceDevices.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Empty, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import api from '../api';
|
||||
import PermissionButton from '../components/PermissionButton';
|
||||
import { message } from '../ui/app-message';
|
||||
|
||||
interface ClassroomOption {
|
||||
id: number;
|
||||
name: string;
|
||||
building?: string | null;
|
||||
}
|
||||
|
||||
interface AttendanceDeviceRow {
|
||||
id: number;
|
||||
deviceSn: string;
|
||||
deviceName: string;
|
||||
classroomId: number;
|
||||
classroom?: ClassroomOption | null;
|
||||
status: 'active' | 'disabled';
|
||||
location?: string | null;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
const statusMeta = {
|
||||
active: { text: '启用', color: 'green' },
|
||||
disabled: { text: '停用', color: 'default' },
|
||||
} as const;
|
||||
|
||||
const AttendanceDevicesPage: React.FC = () => {
|
||||
const [data, setData] = useState<AttendanceDeviceRow[]>([]);
|
||||
const [classrooms, setClassrooms] = useState<ClassroomOption[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<AttendanceDeviceRow | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [devices, classroomList] = await Promise.all([
|
||||
api.get<AttendanceDeviceRow[]>('/attendance-devices'),
|
||||
api.get<ClassroomOption[]>('/classrooms'),
|
||||
]);
|
||||
setData(devices);
|
||||
setClassrooms(classroomList.filter((item: any) => item.status !== 'archived'));
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '加载考勤机绑定失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, []);
|
||||
|
||||
const classroomOptions = useMemo(
|
||||
() => classrooms.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.building ? `${item.name}(${item.building})` : item.name,
|
||||
})),
|
||||
[classrooms],
|
||||
);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
const text = keyword.trim().toLocaleLowerCase('zh-CN');
|
||||
if (!text) return data;
|
||||
return data.filter((item) => [
|
||||
item.deviceSn,
|
||||
item.deviceName,
|
||||
item.classroom?.name,
|
||||
item.location,
|
||||
].some((value) => (value || '').toLocaleLowerCase('zh-CN').includes(text)));
|
||||
}, [data, keyword]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ status: 'active' });
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (record: AttendanceDeviceRow) => {
|
||||
setEditing(record);
|
||||
form.setFieldsValue({
|
||||
deviceSn: record.deviceSn,
|
||||
deviceName: record.deviceName,
|
||||
classroomId: record.classroomId,
|
||||
status: record.status,
|
||||
location: record.location,
|
||||
notes: record.notes,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) {
|
||||
await api.put(`/attendance-devices/${editing.id}`, values);
|
||||
message.success('考勤机绑定已更新');
|
||||
} else {
|
||||
await api.post('/attendance-devices', values);
|
||||
message.success('考勤机绑定已创建');
|
||||
}
|
||||
setModalOpen(false);
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
await loadData();
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await api.delete(`/attendance-devices/${id}`);
|
||||
message.success('已删除绑定');
|
||||
await loadData();
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<AttendanceDeviceRow> = [
|
||||
{ title: '设备名称', dataIndex: 'deviceName', width: 180 },
|
||||
{ title: 'SN 码', dataIndex: 'deviceSn', width: 220, render: (value) => <span style={{ fontFamily: 'monospace' }}>{value}</span> },
|
||||
{ title: '绑定教室', dataIndex: ['classroom', 'name'], width: 160, render: (_value, record) => record.classroom?.name || `教室 ${record.classroomId}` },
|
||||
{ title: '位置', dataIndex: 'location', render: (value) => value || <span style={{ color: '#999' }}>—</span> },
|
||||
{ title: '状态', dataIndex: 'status', width: 90, render: (value: keyof typeof statusMeta) => <Tag color={statusMeta[value]?.color}>{statusMeta[value]?.text || value}</Tag> },
|
||||
{ title: '备注', dataIndex: 'notes', ellipsis: true, render: (value) => value || <span style={{ color: '#999' }}>—</span> },
|
||||
{
|
||||
title: '操作',
|
||||
width: 150,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<PermissionButton permission="classroom:edit" size="small" type="link" onClick={() => openEdit(record)}>
|
||||
编辑
|
||||
</PermissionButton>
|
||||
<Popconfirm title="确定删除此考勤机绑定?" onConfirm={() => handleDelete(record.id)}>
|
||||
<PermissionButton permission="classroom:edit" size="small" danger>
|
||||
删除
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
|
||||
<Input.Search
|
||||
allowClear
|
||||
placeholder="搜索设备/SN/教室"
|
||||
style={{ width: 260 }}
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
/>
|
||||
<PermissionButton permission="classroom:edit" type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
添加考勤机
|
||||
</PermissionButton>
|
||||
</div>
|
||||
<Table<AttendanceDeviceRow>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={filteredData}
|
||||
loading={loading}
|
||||
locale={{ emptyText: <Empty description="暂无考勤机绑定" /> }}
|
||||
pagination={{ defaultPageSize: 20, showSizeChanger: true }}
|
||||
/>
|
||||
<Modal
|
||||
title={editing ? '编辑考勤机绑定' : '添加考勤机绑定'}
|
||||
open={modalOpen}
|
||||
onOk={handleSave}
|
||||
onCancel={() => {
|
||||
setModalOpen(false);
|
||||
setEditing(null);
|
||||
}}
|
||||
confirmLoading={saving}
|
||||
okText="保存"
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="deviceName" label="设备名称" rules={[{ required: true, message: '请输入设备名称' }]}>
|
||||
<Input placeholder="如:彼岸游境_N1604" />
|
||||
</Form.Item>
|
||||
<Form.Item name="deviceSn" label="SN 码" rules={[{ required: true, message: '请输入钉钉返回的 deviceSN' }]}>
|
||||
<Input placeholder="如:300419260325WN1604" />
|
||||
</Form.Item>
|
||||
<Form.Item name="classroomId" label="绑定教室" rules={[{ required: true, message: '请选择绑定教室' }]}>
|
||||
<Select showSearch optionFilterProp="label" options={classroomOptions} placeholder="选择教室" />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态" initialValue="active">
|
||||
<Select options={[{ value: 'active', label: '启用' }, { value: 'disabled', label: '停用' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="location" label="位置">
|
||||
<Input placeholder="如:教学楼一楼东侧" />
|
||||
</Form.Item>
|
||||
<Form.Item name="notes" label="备注">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttendanceDevicesPage;
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
ClassSchedule,
|
||||
AttendanceRecord,
|
||||
AttendanceSession,
|
||||
AttendanceDevice,
|
||||
DingAttendanceRaw,
|
||||
SyncLog,
|
||||
SyncState,
|
||||
@@ -64,6 +65,7 @@ import { ClassroomsModule } from './classrooms/classrooms.module';
|
||||
import { ClassesModule } from './classes/classes.module';
|
||||
import { OrganizationsModule } from './organizations/organizations.module';
|
||||
import { AttendanceModule } from './attendance/attendance.module';
|
||||
import { AttendanceDevicesModule } from './attendance-devices/attendance-devices.module';
|
||||
import { SchedulesModule } from './schedules/schedules.module';
|
||||
import { ClassroomRentalsModule } from './classroom-rentals/classroom-rentals.module';
|
||||
import { SyncModule } from './sync/sync.module';
|
||||
@@ -123,6 +125,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
|
||||
ClassSchedule,
|
||||
AttendanceRecord,
|
||||
AttendanceSession,
|
||||
AttendanceDevice,
|
||||
DingAttendanceRaw,
|
||||
Notification,
|
||||
StudentProfile,
|
||||
@@ -176,6 +179,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
|
||||
WalletsModule,
|
||||
ClassroomsModule,
|
||||
AttendanceModule,
|
||||
AttendanceDevicesModule,
|
||||
ClassesModule,
|
||||
OrganizationsModule,
|
||||
SchedulesModule,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post, Put, Query, Request, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import { extractRequestInfo } from '../common/request-utils';
|
||||
import { AttendanceDevicesService } from './attendance-devices.service';
|
||||
import { CreateAttendanceDeviceDto, UpdateAttendanceDeviceDto } from './dto/attendance-device.dto';
|
||||
import { AttendanceDeviceStatus } from '../entities';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('attendance-devices')
|
||||
export class AttendanceDevicesController {
|
||||
constructor(
|
||||
private readonly service: AttendanceDevicesService,
|
||||
private readonly logService: OperationLogsService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('classroom:view')
|
||||
findAll(
|
||||
@Query('classroomId') classroomId?: string,
|
||||
@Query('status') status?: AttendanceDeviceStatus | 'active' | 'disabled',
|
||||
) {
|
||||
return this.service.findAll({
|
||||
classroomId: classroomId ? Number(classroomId) : undefined,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermission('classroom:edit')
|
||||
async create(@Body() dto: CreateAttendanceDeviceDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.create(dto);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '考勤机',
|
||||
action: '新增考勤机绑定',
|
||||
targetId: result.id,
|
||||
targetType: 'attendanceDevice',
|
||||
detail: `${result.deviceSn} -> ${result.classroom?.name || result.classroomId}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@RequirePermission('classroom:edit')
|
||||
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateAttendanceDeviceDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.update(id, dto);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '考勤机',
|
||||
action: '编辑考勤机绑定',
|
||||
targetId: id,
|
||||
targetType: 'attendanceDevice',
|
||||
detail: JSON.stringify(dto),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('classroom:edit')
|
||||
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.remove(id);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '考勤机',
|
||||
action: '删除考勤机绑定',
|
||||
targetId: id,
|
||||
targetType: 'attendanceDevice',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AttendanceDevice, Classroom } from '../entities';
|
||||
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
||||
import { AttendanceDevicesController } from './attendance-devices.controller';
|
||||
import { AttendanceDevicesService } from './attendance-devices.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([AttendanceDevice, Classroom]), OperationLogsModule],
|
||||
controllers: [AttendanceDevicesController],
|
||||
providers: [AttendanceDevicesService],
|
||||
exports: [AttendanceDevicesService],
|
||||
})
|
||||
export class AttendanceDevicesModule {}
|
||||
105
apps/server/src/attendance-devices/attendance-devices.service.ts
Normal file
105
apps/server/src/attendance-devices/attendance-devices.service.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { AttendanceDevice, AttendanceDeviceStatus, Classroom } from '../entities';
|
||||
import { CreateAttendanceDeviceDto, UpdateAttendanceDeviceDto } from './dto/attendance-device.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AttendanceDevicesService {
|
||||
constructor(
|
||||
@InjectRepository(AttendanceDevice)
|
||||
private readonly repo: Repository<AttendanceDevice>,
|
||||
@InjectRepository(Classroom)
|
||||
private readonly classroomRepo: Repository<Classroom>,
|
||||
) {}
|
||||
|
||||
private normalizeSn(sn: string): string {
|
||||
return sn.trim();
|
||||
}
|
||||
|
||||
private async assertClassroomExists(classroomId: number): Promise<void> {
|
||||
const exists = await this.classroomRepo.exist({ where: { id: classroomId } });
|
||||
if (!exists) throw new BadRequestException('绑定教室不存在');
|
||||
}
|
||||
|
||||
async findAll(query?: { classroomId?: number; status?: AttendanceDeviceStatus | 'active' | 'disabled' }) {
|
||||
const where: Record<string, unknown> = {};
|
||||
if (query?.classroomId) where.classroomId = query.classroomId;
|
||||
if (query?.status) where.status = query.status;
|
||||
return this.repo.find({
|
||||
where,
|
||||
relations: ['classroom'],
|
||||
order: { classroomId: 'ASC', deviceName: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: number) {
|
||||
const device = await this.repo.findOne({ where: { id }, relations: ['classroom'] });
|
||||
if (!device) throw new NotFoundException('考勤机不存在');
|
||||
return device;
|
||||
}
|
||||
|
||||
async create(dto: CreateAttendanceDeviceDto) {
|
||||
const deviceSn = this.normalizeSn(dto.deviceSn);
|
||||
await this.assertClassroomExists(dto.classroomId);
|
||||
const exists = await this.repo.findOne({ where: { deviceSn } });
|
||||
if (exists) throw new BadRequestException(`SN ${deviceSn} 已绑定教室`);
|
||||
const saved = await this.repo.save(
|
||||
this.repo.create({
|
||||
...dto,
|
||||
deviceSn,
|
||||
deviceName: dto.deviceName.trim(),
|
||||
status: dto.status ?? AttendanceDeviceStatus.ACTIVE,
|
||||
}),
|
||||
);
|
||||
return this.findOne(saved.id);
|
||||
}
|
||||
|
||||
async update(id: number, dto: UpdateAttendanceDeviceDto) {
|
||||
const device = await this.repo.findOne({ where: { id } });
|
||||
if (!device) throw new NotFoundException('考勤机不存在');
|
||||
const patch: Partial<AttendanceDevice> = { ...dto };
|
||||
if (dto.classroomId != null) await this.assertClassroomExists(dto.classroomId);
|
||||
if (dto.deviceSn != null) {
|
||||
const deviceSn = this.normalizeSn(dto.deviceSn);
|
||||
const exists = await this.repo.findOne({ where: { deviceSn } });
|
||||
if (exists && exists.id !== id) throw new BadRequestException(`SN ${deviceSn} 已绑定教室`);
|
||||
patch.deviceSn = deviceSn;
|
||||
}
|
||||
if (dto.deviceName != null) patch.deviceName = dto.deviceName.trim();
|
||||
await this.repo.update(id, patch);
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
async remove(id: number) {
|
||||
const device = await this.repo.findOne({ where: { id } });
|
||||
if (!device) throw new NotFoundException('考勤机不存在');
|
||||
await this.repo.delete(id);
|
||||
return { message: '已删除' };
|
||||
}
|
||||
|
||||
async findActiveBySn(deviceSns: string[]) {
|
||||
const sns = [...new Set(deviceSns.map((sn) => this.normalizeSn(sn)).filter(Boolean))];
|
||||
if (sns.length === 0) return new Map<string, AttendanceDevice>();
|
||||
const devices = await this.repo.find({
|
||||
where: { deviceSn: In(sns), status: AttendanceDeviceStatus.ACTIVE },
|
||||
relations: ['classroom'],
|
||||
});
|
||||
return new Map(devices.map((device) => [device.deviceSn, device]));
|
||||
}
|
||||
|
||||
async findActiveByClassroomIds(classroomIds: number[]) {
|
||||
const ids = [...new Set(classroomIds.filter((id) => Number.isFinite(id)))];
|
||||
if (ids.length === 0) return new Map<number, AttendanceDevice>();
|
||||
const devices = await this.repo.find({
|
||||
where: { classroomId: In(ids), status: AttendanceDeviceStatus.ACTIVE },
|
||||
relations: ['classroom'],
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
const result = new Map<number, AttendanceDevice>();
|
||||
for (const device of devices) {
|
||||
if (!result.has(device.classroomId)) result.set(device.classroomId, device);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { IsEnum, IsInt, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { AttendanceDeviceStatus } from '../../entities/attendance-device.entity';
|
||||
|
||||
export class CreateAttendanceDeviceDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
deviceSn: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
deviceName: string;
|
||||
|
||||
@IsInt()
|
||||
classroomId: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(AttendanceDeviceStatus)
|
||||
status?: AttendanceDeviceStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
location?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class UpdateAttendanceDeviceDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
deviceSn?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
deviceName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
classroomId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(AttendanceDeviceStatus)
|
||||
status?: AttendanceDeviceStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
location?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AttendanceRecord, AttendanceSession, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping } from '../entities';
|
||||
import { AttendanceRecord, AttendanceSession, AttendanceDevice, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping } from '../entities';
|
||||
import { AttendanceService } from './attendance.service';
|
||||
import { AttendanceImportService } from './attendance-import.service';
|
||||
import { AttendanceSettlementService } from './attendance-settlement.service';
|
||||
@@ -10,7 +10,7 @@ import { IntegrationModule } from '../integration/integration.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([AttendanceRecord, AttendanceSession, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping]),
|
||||
TypeOrmModule.forFeature([AttendanceRecord, AttendanceSession, AttendanceDevice, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping]),
|
||||
OperationLogsModule,
|
||||
IntegrationModule,
|
||||
],
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Repository, In, Between, LessThanOrEqual, MoreThanOrEqual, DataSource }
|
||||
import {
|
||||
AttendanceRecord,
|
||||
AttendanceSession,
|
||||
AttendanceDevice,
|
||||
DingAttendanceRaw,
|
||||
Class,
|
||||
Student,
|
||||
@@ -66,11 +67,71 @@ export class AttendanceService {
|
||||
private classTeacherRepo: Repository<ClassTeacher>,
|
||||
@InjectRepository(AttendanceSession)
|
||||
private attendanceSessionRepo: Repository<AttendanceSession>,
|
||||
@InjectRepository(AttendanceDevice)
|
||||
private attendanceDeviceRepo: Repository<AttendanceDevice>,
|
||||
private dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
private sessionMutex = new SessionMutex();
|
||||
|
||||
private formatDeviceDetail(device: AttendanceDevice): string {
|
||||
const classroomName = device.classroom?.name;
|
||||
return classroomName ? `${device.deviceName} · ${classroomName}` : device.deviceName;
|
||||
}
|
||||
|
||||
private async attachAttendanceDeviceMappings<T extends AttendanceRecord>(
|
||||
records: T[],
|
||||
classroomId?: number | null,
|
||||
): Promise<T[]> {
|
||||
if (records.length === 0) return records;
|
||||
const sns = [...new Set(records.map((record) => record.punchDeviceId?.trim()).filter(Boolean) as string[])];
|
||||
const devicesBySn = new Map<string, AttendanceDevice>();
|
||||
if (sns.length > 0) {
|
||||
const devices = await this.attendanceDeviceRepo.find({
|
||||
where: { deviceSn: In(sns), status: 'active' },
|
||||
relations: ['classroom'],
|
||||
});
|
||||
for (const device of devices) devicesBySn.set(device.deviceSn, device);
|
||||
}
|
||||
|
||||
const classroomIds = [...new Set([
|
||||
...records.map((record) => record.classId).filter((id): id is number => id != null),
|
||||
...(classroomId != null ? [classroomId] : []),
|
||||
])];
|
||||
const devicesByClassroom = new Map<number, AttendanceDevice>();
|
||||
if (classroomIds.length > 0) {
|
||||
const devices = await this.attendanceDeviceRepo.find({
|
||||
where: { classroomId: In(classroomIds), status: 'active' },
|
||||
relations: ['classroom'],
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
for (const device of devices) {
|
||||
if (!devicesByClassroom.has(device.classroomId)) devicesByClassroom.set(device.classroomId, device);
|
||||
}
|
||||
}
|
||||
|
||||
for (const record of records) {
|
||||
const sn = record.punchDeviceId?.trim();
|
||||
const mappedBySn = sn ? devicesBySn.get(sn) : undefined;
|
||||
if (mappedBySn) {
|
||||
record.punchDeviceName = this.formatDeviceDetail(mappedBySn);
|
||||
record.punchDeviceId = mappedBySn.deviceSn;
|
||||
continue;
|
||||
}
|
||||
const source = (record.punchSource || '').trim().toUpperCase();
|
||||
const isMachine = ['ATM', 'ATTENDANCE_MACHINE', 'MACHINE', 'DEVICE'].some(
|
||||
(value) => source === value || source.includes(value),
|
||||
);
|
||||
const fallbackClassroomId = record.classId ?? classroomId ?? undefined;
|
||||
const mappedByClassroom = fallbackClassroomId ? devicesByClassroom.get(fallbackClassroomId) : undefined;
|
||||
if (isMachine && mappedByClassroom && !record.punchDeviceName) {
|
||||
record.punchDeviceName = this.formatDeviceDetail(mappedByClassroom);
|
||||
record.punchDeviceId = record.punchDeviceId || mappedByClassroom.deviceSn;
|
||||
}
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
|
||||
if (canManageAll) return undefined;
|
||||
const assignments = await this.classTeacherRepo.find({ where: { userId } });
|
||||
@@ -172,7 +233,7 @@ export class AttendanceService {
|
||||
order: { studentId: 'ASC' },
|
||||
})
|
||||
: [];
|
||||
return { schedule, session, records };
|
||||
return { schedule, session, records: await this.attachAttendanceDeviceMappings(records, schedule.classId) };
|
||||
}
|
||||
|
||||
private getLessonAttendanceWindow(
|
||||
@@ -299,7 +360,7 @@ export class AttendanceService {
|
||||
relations: ['student'],
|
||||
order: { studentId: 'ASC' },
|
||||
});
|
||||
return { schedule, session: existing, records };
|
||||
return { schedule, session: existing, records: await this.attachAttendanceDeviceMappings(records, schedule.classId) };
|
||||
}
|
||||
if (existing.status !== 'in_progress' && !(finalize && existing.status === 'settling')) {
|
||||
throw new BadRequestException('课程考勤正在结算');
|
||||
@@ -385,7 +446,7 @@ export class AttendanceService {
|
||||
existing.completedAt = new Date();
|
||||
await sessionRepo.save(existing);
|
||||
}
|
||||
return { schedule, session: existing, records: saved };
|
||||
return { schedule, session: existing, records: await this.attachAttendanceDeviceMappings(saved, schedule.classId) };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -428,7 +489,7 @@ export class AttendanceService {
|
||||
relations: ['student'],
|
||||
order: { studentId: 'ASC' },
|
||||
});
|
||||
return { schedule, session, records: existingRecords };
|
||||
return { schedule, session, records: await this.attachAttendanceDeviceMappings(existingRecords, schedule.classId) };
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
@@ -469,7 +530,7 @@ export class AttendanceService {
|
||||
session.completedAt = new Date();
|
||||
session = await sessionRepo.save(session);
|
||||
}
|
||||
return { schedule, session, records: saved };
|
||||
return { schedule, session, records: await this.attachAttendanceDeviceMappings(saved, schedule.classId) };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -516,7 +577,7 @@ export class AttendanceService {
|
||||
relations: ['student'],
|
||||
order: { studentId: 'ASC' },
|
||||
});
|
||||
return { session, records };
|
||||
return { session, records: await this.attachAttendanceDeviceMappings(records, session.classId) };
|
||||
}
|
||||
|
||||
const pendingRecords = await recordRepo.count({
|
||||
@@ -535,7 +596,7 @@ export class AttendanceService {
|
||||
relations: ['student'],
|
||||
order: { studentId: 'ASC' },
|
||||
});
|
||||
return { session: savedSession, records };
|
||||
return { session: savedSession, records: await this.attachAttendanceDeviceMappings(records, session.classId) };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
await this.ensureAiConfigTable();
|
||||
await this.ensureCourseAttendanceSchema();
|
||||
await this.ensureAttendanceDevicesSchema();
|
||||
await this.ensureStudentWalletSchema();
|
||||
await this.backfillOrganizations();
|
||||
await this.normalizeClassDates();
|
||||
@@ -22,6 +23,64 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
||||
await this.normalizeClassroomStatuses();
|
||||
}
|
||||
|
||||
private async ensureAttendanceDevicesSchema(): Promise<void> {
|
||||
const runner = this.dataSource.createQueryRunner();
|
||||
await runner.connect();
|
||||
try {
|
||||
const isMySQL = this.dataSource.options.type === 'mysql';
|
||||
const pk = isMySQL ? 'INTEGER PRIMARY KEY AUTO_INCREMENT' : 'INTEGER PRIMARY KEY AUTOINCREMENT';
|
||||
await runner.query(`CREATE TABLE IF NOT EXISTS attendance_devices (
|
||||
id ${pk},
|
||||
device_sn VARCHAR(100) NOT NULL,
|
||||
device_name VARCHAR(100) NOT NULL,
|
||||
classroom_id INTEGER NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
location VARCHAR(200),
|
||||
notes TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`);
|
||||
const table = await runner.getTable('attendance_devices');
|
||||
const columnNames = new Set(table?.columns.map((column) => column.name) ?? []);
|
||||
const additions: Array<[string, string]> = [
|
||||
['device_sn', 'VARCHAR(100) NOT NULL DEFAULT \'\''],
|
||||
['device_name', 'VARCHAR(100) NOT NULL DEFAULT \'\''],
|
||||
['classroom_id', 'INTEGER NOT NULL DEFAULT 0'],
|
||||
['status', "VARCHAR(20) NOT NULL DEFAULT 'active'"],
|
||||
['location', 'VARCHAR(200)'],
|
||||
['notes', 'TEXT'],
|
||||
['created_at', 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP'],
|
||||
['updated_at', 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP'],
|
||||
];
|
||||
for (const [name, definition] of additions) {
|
||||
if (!columnNames.has(name)) await runner.query(`ALTER TABLE attendance_devices ADD COLUMN ${name} ${definition}`);
|
||||
}
|
||||
const refreshed = await runner.getTable('attendance_devices');
|
||||
const createIndex = async (sql: string) => {
|
||||
try {
|
||||
await runner.query(sql);
|
||||
} catch {
|
||||
// Existing MySQL indexes cannot use IF NOT EXISTS; startup must stay idempotent.
|
||||
}
|
||||
};
|
||||
const uniqueSn = refreshed?.indices.some((index) => index.columnNames.length === 1 && index.columnNames[0] === 'device_sn' && index.isUnique);
|
||||
if (!uniqueSn) {
|
||||
await createIndex(
|
||||
isMySQL
|
||||
? 'CREATE UNIQUE INDEX idx_attendance_devices_device_sn ON attendance_devices (device_sn)'
|
||||
: 'CREATE UNIQUE INDEX IF NOT EXISTS idx_attendance_devices_device_sn ON attendance_devices (device_sn)',
|
||||
);
|
||||
}
|
||||
await createIndex(
|
||||
isMySQL
|
||||
? 'CREATE INDEX idx_attendance_devices_classroom_id ON attendance_devices (classroom_id)'
|
||||
: 'CREATE INDEX IF NOT EXISTS idx_attendance_devices_classroom_id ON attendance_devices (classroom_id)',
|
||||
);
|
||||
} finally {
|
||||
await runner.release();
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureStudentWalletSchema(): Promise<void> {
|
||||
const runner = this.dataSource.createQueryRunner();
|
||||
await runner.connect();
|
||||
@@ -456,25 +515,57 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
||||
|
||||
private async normalizeClassDates(): Promise<void> {
|
||||
const driver = this.dataSource.options.type;
|
||||
const dateExpression = (column: string) =>
|
||||
driver === 'mysql' ? `DATE(${column})` : `substr(${column}, 1, 10)`;
|
||||
let columns: Array<'start_date' | 'end_date'> = ['start_date', 'end_date'];
|
||||
|
||||
const runner = this.dataSource.createQueryRunner();
|
||||
await runner.connect();
|
||||
try {
|
||||
const table = await runner.getTable('classes');
|
||||
if (!table) return;
|
||||
|
||||
// Fresh MySQL schemas created by TypeORM already use native DATE columns.
|
||||
// This cleanup is only for legacy schemas that stored dates as strings;
|
||||
// comparing a native DATE column with '' raises ER_TRUNCATED_WRONG_VALUE
|
||||
// in strict SQL mode.
|
||||
if (driver === 'mysql') {
|
||||
columns = columns.filter((columnName) => {
|
||||
const column = table.columns.find((item) => item.name === columnName);
|
||||
const type = String(column?.type ?? '').toLowerCase();
|
||||
return !['date', 'datetime', 'timestamp'].includes(type);
|
||||
});
|
||||
if (columns.length === 0) return;
|
||||
}
|
||||
} finally {
|
||||
await runner.release();
|
||||
}
|
||||
|
||||
const columnText = (column: string) =>
|
||||
driver === 'mysql' ? `CAST(${column} AS CHAR)` : column;
|
||||
const firstTenChars = (column: string) =>
|
||||
driver === 'mysql'
|
||||
? `NULLIF(LEFT(${columnText(column)}, 10), '')`
|
||||
: `NULLIF(substr(${column}, 1, 10), '')`;
|
||||
const normalizedDate = (column: string) => `CASE
|
||||
WHEN ${column} IS NULL THEN NULL
|
||||
ELSE ${firstTenChars(column)}
|
||||
END`;
|
||||
const lengthFunction = driver === 'mysql' ? 'CHAR_LENGTH' : 'length';
|
||||
const needsNormalization = (column: string) => `(
|
||||
${column} IS NOT NULL
|
||||
AND (${columnText(column)} = '' OR ${lengthFunction}(${columnText(column)}) > 10)
|
||||
)`;
|
||||
|
||||
const assignments = columns
|
||||
.map((column) => `${column} = ${normalizedDate(column)}`)
|
||||
.join(',\n ');
|
||||
const predicates = columns.map((column) => needsNormalization(column)).join('\n OR ');
|
||||
const result = await this.dataSource.transaction((manager) =>
|
||||
manager.query(`
|
||||
UPDATE classes
|
||||
SET
|
||||
start_date = CASE
|
||||
WHEN start_date IS NULL OR start_date = '' THEN start_date
|
||||
ELSE ${dateExpression('start_date')}
|
||||
END,
|
||||
end_date = CASE
|
||||
WHEN end_date IS NULL OR end_date = '' THEN end_date
|
||||
ELSE ${dateExpression('end_date')}
|
||||
END
|
||||
${assignments}
|
||||
WHERE
|
||||
(start_date IS NOT NULL AND ${lengthFunction}(start_date) > 10)
|
||||
OR (end_date IS NOT NULL AND ${lengthFunction}(end_date) > 10)
|
||||
${predicates}
|
||||
`),
|
||||
);
|
||||
|
||||
|
||||
52
apps/server/src/entities/attendance-device.entity.ts
Normal file
52
apps/server/src/entities/attendance-device.entity.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { Classroom } from './classroom.entity';
|
||||
|
||||
export enum AttendanceDeviceStatus {
|
||||
ACTIVE = 'active',
|
||||
DISABLED = 'disabled',
|
||||
}
|
||||
|
||||
@Entity('attendance_devices')
|
||||
@Index(['deviceSn'], { unique: true })
|
||||
@Index(['classroomId'])
|
||||
export class AttendanceDevice {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ name: 'device_sn', type: 'varchar', length: 100 })
|
||||
deviceSn: string;
|
||||
|
||||
@Column({ name: 'device_name', type: 'varchar', length: 100 })
|
||||
deviceName: string;
|
||||
|
||||
@Column({ name: 'classroom_id', type: 'integer' })
|
||||
classroomId: number;
|
||||
|
||||
@ManyToOne(() => Classroom, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'classroom_id' })
|
||||
classroom: Classroom;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: AttendanceDeviceStatus.ACTIVE })
|
||||
status: AttendanceDeviceStatus | 'active' | 'disabled';
|
||||
|
||||
@Column({ type: 'varchar', length: 200, nullable: true })
|
||||
location: string | null;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes: string | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export { ClassTeacher, TeacherRoleType } from './class-teacher.entity';
|
||||
export { ClassSchedule, ScheduleType } from './class-schedule.entity';
|
||||
export { AttendanceRecord } from './attendance-record.entity';
|
||||
export { AttendanceSession } from './attendance-session.entity';
|
||||
export { AttendanceDevice, AttendanceDeviceStatus } from './attendance-device.entity';
|
||||
export { DingAttendanceRaw } from './ding-attendance-raw.entity';
|
||||
export { SyncLog } from './sync-log.entity';
|
||||
export { SyncState } from './sync-state.entity';
|
||||
|
||||
@@ -515,7 +515,7 @@ export class DingTalkService {
|
||||
checkType?: string; timeResult?: string;
|
||||
locationResult?: string; locationMethod?: string;
|
||||
userAddress?: string; userLongitude?: number; userLatitude?: number;
|
||||
deviceName?: string; deviceId?: string | number;
|
||||
deviceName?: string; deviceId?: string | number; deviceSN?: string | number;
|
||||
attendanceMachineName?: string; attendanceMachineId?: string | number;
|
||||
}>;
|
||||
};
|
||||
@@ -535,7 +535,7 @@ export class DingTalkService {
|
||||
checkType: r.checkType ?? '',
|
||||
sourceType: r.sourceType ?? '',
|
||||
deviceName: r.deviceName ?? r.attendanceMachineName,
|
||||
deviceId: String(r.deviceId ?? r.attendanceMachineId ?? '') || undefined,
|
||||
deviceId: String(r.deviceId ?? r.attendanceMachineId ?? r.deviceSN ?? '') || undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user