feat: show DingTalk punch device details

This commit is contained in:
2026-07-14 11:20:27 +08:00
parent d572e984d2
commit 811e7ce826
12 changed files with 377 additions and 13 deletions

View File

@@ -93,6 +93,31 @@ describe('AttendanceImportService', () => {
expect(entity.userName).toBe('张三');
});
it('stores DingTalk punch source and attendance machine metadata', async () => {
const entity = await (service as any).mapToEntity({
userId: 'ding-1',
userName: '张三',
workDate: '2026-07-01',
timeResult: 'Normal',
locationResult: '',
planCheckTime: '',
actualCheckTime: '2026-07-01T08:00:00.000Z',
checkId: 'check-1',
checkType: 'OnDuty',
sourceType: 'ATM',
deviceName: '东门考勤机',
deviceId: 'ATM-01',
});
expect(entity).toEqual(
expect.objectContaining({
punchSource: 'ATM',
punchDeviceName: '东门考勤机',
punchDeviceId: 'ATM-01',
}),
);
});
it('fills the student name from the DingTalk mapping when saving an imported record', async () => {
dingTalkService.fetchAttendanceResults.mockResolvedValue([
{
@@ -138,9 +163,19 @@ describe('AttendanceImportService', () => {
actualCheckTime: '2026-07-01T08:00:00.000Z',
checkId: 'check-1',
checkType: 'OnDuty',
sourceType: 'ATM',
deviceName: '东门考勤机',
deviceId: 'ATM-01',
},
]);
dingRawRepo.find.mockResolvedValue([{ dingId: 'check-1' }]);
dingRawRepo.find.mockResolvedValue([{
dingId: 'check-1',
punchSource: null,
punchDeviceName: null,
punchDeviceId: null,
rawData: '',
}]);
dingRawRepo.save.mockImplementation(async (entities) => entities);
attendanceService.autoMatchDingRecords.mockResolvedValue({ matched: 1, total: 1 });
const result = await service.importFromDingTalk({
@@ -150,10 +185,57 @@ describe('AttendanceImportService', () => {
autoMatch: true,
});
expect(dingRawRepo.save).toHaveBeenCalledWith(
[expect.objectContaining({
dingId: 'check-1',
punchSource: 'ATM',
punchDeviceName: '东门考勤机',
punchDeviceId: 'ATM-01',
})],
{ chunk: 50 },
);
expect(attendanceService.autoMatchDingRecords).toHaveBeenCalled();
expect(result.matched).toBe(1);
});
it('preserves existing device metadata when a duplicate response omits it', async () => {
const existing = {
dingId: 'check-keep-device',
punchSource: 'ATM',
punchDeviceName: '东门考勤机',
punchDeviceId: 'ATM-01',
rawData: '{}',
};
dingTalkService.fetchAttendanceResults.mockResolvedValue([{
userId: 'ding-1',
userName: '张三',
workDate: '2026-07-01',
timeResult: 'Normal',
locationResult: '',
planCheckTime: '',
actualCheckTime: '2026-07-01T08:00:00.000Z',
checkId: 'check-keep-device',
checkType: 'OnDuty',
sourceType: '',
}]);
dingRawRepo.find.mockResolvedValue([existing]);
attendanceService.autoMatchDingRecords.mockResolvedValue({ matched: 0, total: 1 });
await service.importFromDingTalk({
startDate: '2026-07-01',
endDate: '2026-07-01',
userIds: ['ding-1'],
autoMatch: true,
});
expect(existing).toEqual(expect.objectContaining({
punchSource: 'ATM',
punchDeviceName: '东门考勤机',
punchDeviceId: 'ATM-01',
}));
expect(dingRawRepo.save).not.toHaveBeenCalled();
});
it('scopes SSE progress events to the importing user', async () => {
dingTalkService.fetchAttendanceResults.mockResolvedValue([
{

View File

@@ -104,8 +104,10 @@ export class AttendanceImportService {
// Stage 2: Parse & deduplicate
this.emit('parsing', 0, total, `Parsing ${total} records...`);
const existingDingIds = await this.getExistingDingIds(rawResults);
const newRecords = rawResults.filter((r) => !existingDingIds.has(r.checkId));
const existingByDingId = await this.getExistingRecordsByDingId(rawResults);
const newRecords = rawResults.filter((r) => !existingByDingId.has(r.checkId));
const duplicateRecords = rawResults.filter((r) => existingByDingId.has(r.checkId));
await this.refreshDuplicatePunchMetadata(duplicateRecords, existingByDingId);
skipped = rawResults.length - newRecords.length;
this.emit('parsing', newRecords.length, total, `${newRecords.length} new records, ${skipped} duplicates skipped`);
@@ -246,17 +248,45 @@ export class AttendanceImportService {
/**
* Query which dingIds already exist to skip duplicates.
*/
private async getExistingDingIds(
private async getExistingRecordsByDingId(
results: DingTalkAttendanceResult[],
): Promise<Set<string>> {
): Promise<Map<string, DingAttendanceRaw>> {
const dingIds = results.map((r) => r.checkId).filter(Boolean);
if (dingIds.length === 0) return new Set();
if (dingIds.length === 0) return new Map();
const existing = await this.dingRawRepo.find({
where: { dingId: In(dingIds) },
select: ['dingId'],
});
return new Set(existing.map((e) => e.dingId));
return new Map(existing.map((entity) => [entity.dingId, entity]));
}
private async refreshDuplicatePunchMetadata(
results: DingTalkAttendanceResult[],
existingByDingId: Map<string, DingAttendanceRaw>,
): Promise<void> {
const changed: DingAttendanceRaw[] = [];
for (const result of results) {
const entity = existingByDingId.get(result.checkId);
if (!entity) continue;
const punchSource = result.sourceType || entity.punchSource || null;
const punchDeviceName = result.deviceName || entity.punchDeviceName || null;
const punchDeviceId = result.deviceId || entity.punchDeviceId || null;
if (
entity.punchSource === punchSource &&
entity.punchDeviceName === punchDeviceName &&
entity.punchDeviceId === punchDeviceId
) {
continue;
}
entity.punchSource = punchSource;
entity.punchDeviceName = punchDeviceName;
entity.punchDeviceId = punchDeviceId;
entity.rawData = JSON.stringify(result);
changed.push(entity);
}
if (changed.length > 0) {
await this.dingRawRepo.save(changed, { chunk: 50 });
}
}
/**
@@ -271,6 +301,9 @@ export class AttendanceImportService {
entity.attendanceType = r.checkType || 'OnDuty';
entity.timeResult = r.timeResult;
entity.locationResult = r.locationResult || '';
entity.punchSource = r.sourceType || null;
entity.punchDeviceName = r.deviceName || null;
entity.punchDeviceId = r.deviceId || null;
// Parse check-in/out times
if (r.actualCheckTime) {

View File

@@ -79,6 +79,9 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
attendanceType: 'OnDuty',
timeResult: 'Normal',
checkInTime: new Date('2026-07-11T08:55:00+08:00'),
punchSource: 'ATM',
punchDeviceName: '东门考勤机',
punchDeviceId: 'ATM-01',
},
{
matchedStudentId: 2,
@@ -100,7 +103,15 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
}),
);
expect(attendanceRepo.save).toHaveBeenCalledWith([
expect.objectContaining({ studentId: 1, status: 'present', source: 'dingtalk' }),
expect.objectContaining({
studentId: 1,
status: 'present',
source: 'dingtalk',
punchSource: 'ATM',
punchDeviceName: '东门考勤机',
punchDeviceId: 'ATM-01',
punchTime: new Date('2026-07-11T08:55:00+08:00'),
}),
expect.objectContaining({ studentId: 2, status: 'present', source: 'dingtalk' }),
expect.objectContaining({ studentId: 3, status: 'pending', source: 'dingtalk' }),
expect.objectContaining({ studentId: 4, status: 'pending', source: 'dingtalk' }),

View File

@@ -200,6 +200,53 @@ export class AttendanceService {
if (hasPunch) return 'present';
return finalize ? 'absent' : 'pending';
}
private getLessonPunchMetadata(
records: DingAttendanceRaw[],
lessonDate: string,
startTime: string,
): Pick<AttendanceRecord, 'punchTime' | 'punchSource' | 'punchDeviceName' | 'punchDeviceId'> {
const punches = records
.map((record) => ({ record, time: record.checkInTime ?? record.checkOutTime }))
.filter((item): item is { record: DingAttendanceRaw; time: Date } => !!item.time);
if (punches.length === 0) {
return {
punchTime: null,
punchSource: null,
punchDeviceName: null,
punchDeviceId: null,
};
}
const lessonStart = new Date(`${lessonDate}T${startTime}:00+08:00`).getTime();
punches.sort(
(left, right) =>
Math.abs(left.time.getTime() - lessonStart) - Math.abs(right.time.getTime() - lessonStart),
);
const primary = punches[0];
const metadataRecord = [...punches]
.filter(({ record }) =>
!!(record.punchSource || record.punchDeviceName || record.punchDeviceId) ||
!['OnDuty', 'OffDuty'].includes(record.attendanceType),
)
.sort(
(left, right) =>
Math.abs(left.time.getTime() - primary.time.getTime()) -
Math.abs(right.time.getTime() - primary.time.getTime()),
)[0]?.record;
const source =
metadataRecord?.punchSource ||
(metadataRecord && !['OnDuty', 'OffDuty'].includes(metadataRecord.attendanceType)
? metadataRecord.attendanceType
: primary.record.punchSource);
return {
punchTime: primary.time,
punchSource: source || null,
punchDeviceName: metadataRecord?.punchDeviceName || primary.record.punchDeviceName || null,
punchDeviceId: metadataRecord?.punchDeviceId || primary.record.punchDeviceId || null,
};
}
async createLessonAttendanceFromDingTalk(
scheduleId: number,
lessonDate: string,
@@ -270,6 +317,11 @@ export class AttendanceService {
schedule.endTime,
);
record.status = this.mapDingTalkStatus(raw, finalize);
Object.assign(record, this.getLessonPunchMetadata(
raw,
lessonDate,
schedule.startTime,
));
record.remark = raw.some((item) => item.checkInTime || item.checkOutTime)
? null
: finalize
@@ -296,6 +348,11 @@ export class AttendanceService {
session: this.mapScheduleTimeToSession(schedule.startTime),
status: this.mapDingTalkStatus(raw, finalize),
source: 'dingtalk',
...this.getLessonPunchMetadata(
raw,
lessonDate,
schedule.startTime,
),
remark: raw.some((item) => item.checkInTime || item.checkOutTime)
? undefined
: finalize
@@ -378,6 +435,11 @@ export class AttendanceService {
session: this.mapScheduleTimeToSession(schedule.startTime),
status: this.mapDingTalkStatus(raw, finalize),
source: 'dingtalk',
...this.getLessonPunchMetadata(
raw,
lessonDate,
schedule.startTime,
),
remark: raw.some((item) => item.checkInTime || item.checkOutTime)
? undefined
: finalize
@@ -908,6 +970,10 @@ export class AttendanceService {
if (dto.status !== undefined) {
record.status = dto.status;
record.source = 'manual';
record.punchTime = null;
record.punchSource = null;
record.punchDeviceName = null;
record.punchDeviceId = null;
}
if (dto.remark !== undefined) {
record.remark = dto.remark;
@@ -933,6 +999,10 @@ export class AttendanceService {
if (dto.status !== undefined) {
freshRecord.status = dto.status;
freshRecord.source = 'manual';
freshRecord.punchTime = null;
freshRecord.punchSource = null;
freshRecord.punchDeviceName = null;
freshRecord.punchDeviceId = null;
}
if (dto.remark !== undefined) {
freshRecord.remark = dto.remark;

View File

@@ -68,8 +68,10 @@ describe('DingTalkService — attendance records', () => {
userId: 'ding-1',
workDate: Date.parse('2026-07-12T00:00:00+08:00'),
userCheckTime: Date.parse('2026-07-12T21:05:00+08:00'),
sourceType: 'USER',
sourceType: 'ATM',
checkType: 'OnDuty',
deviceName: '东门考勤机',
deviceId: 'ATM-01',
timeResult: 'Normal',
},
],
@@ -83,6 +85,13 @@ describe('DingTalkService — attendance records', () => {
});
expect(record.workDate).toBe('2026-07-12');
expect(record).toEqual(
expect.objectContaining({
checkType: 'OnDuty',
sourceType: 'ATM',
deviceName: '东门考勤机',
deviceId: 'ATM-01',
}),
);
});
});

View File

@@ -67,6 +67,18 @@ export class AttendanceRecord {
@Column({ name: 'source', length: 20, default: 'manual' })
source: string;
@Column({ name: 'punch_time', type: 'datetime', nullable: true })
punchTime: Date | null;
@Column({ name: 'punch_source', type: 'varchar', length: 40, nullable: true })
punchSource: string | null;
@Column({ name: 'punch_device_name', type: 'varchar', length: 100, nullable: true })
punchDeviceName: string | null;
@Column({ name: 'punch_device_id', type: 'varchar', length: 100, nullable: true })
punchDeviceId: string | null;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;

View File

@@ -44,6 +44,15 @@ export class DingAttendanceRaw {
@Column({ name: 'location_result', length: 20, nullable: true })
locationResult: string;
@Column({ name: 'punch_source', type: 'varchar', length: 40, nullable: true })
punchSource: string | null;
@Column({ name: 'punch_device_name', type: 'varchar', length: 100, nullable: true })
punchDeviceName: string | null;
@Column({ name: 'punch_device_id', type: 'varchar', length: 100, nullable: true })
punchDeviceId: string | null;
@Column({ name: 'match_status', length: 20, default: 'unmatched' })
matchStatus: string;

View File

@@ -51,6 +51,11 @@ export interface DingTalkAttendanceResult {
actualCheckTime: string;
checkId: string;
checkType: string;
/** 钉钉返回的打卡来源,例如 ATM / USER / BEACON。 */
sourceType: string;
/** 部分钉钉租户会额外返回考勤机名称或编号。 */
deviceName?: string;
deviceId?: string;
}
// ── 组织架构 API 类型 ──
@@ -505,6 +510,8 @@ export class DingTalkService {
checkType?: string; timeResult?: string;
locationResult?: string; locationMethod?: string;
userAddress?: string; userLongitude?: number; userLatitude?: number;
deviceName?: string; deviceId?: string | number;
attendanceMachineName?: string; attendanceMachineId?: string | number;
}>;
};
if (data.errcode !== 0) throw new Error(`钉钉考勤获取失败: ${data.errmsg}`);
@@ -520,7 +527,10 @@ export class DingTalkService {
planCheckTime: '',
actualCheckTime: new Date(r.userCheckTime).toISOString(),
checkId: String(r.id),
checkType: r.checkType ?? r.sourceType ?? '',
checkType: r.checkType ?? '',
sourceType: r.sourceType ?? '',
deviceName: r.deviceName ?? r.attendanceMachineName,
deviceId: String(r.deviceId ?? r.attendanceMachineId ?? '') || undefined,
}));
}