feat: add attendance device SN classroom bindings

This commit is contained in:
2026-07-16 10:22:23 +08:00
parent d037787346
commit 559b8a56e9
16 changed files with 1323 additions and 23 deletions

View File

@@ -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,

View File

@@ -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;
}
}

View File

@@ -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 {}

View 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;
}
}

View File

@@ -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;
}

View File

@@ -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,
],

View File

@@ -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) };
}),
);
}

View File

@@ -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}
`),
);

View 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;
}

View File

@@ -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';

View File

@@ -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,
}));
}